behavior-wrapped 0.5.21 → 0.7.0

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/index.html CHANGED
@@ -6,8 +6,8 @@
6
6
  <meta name="theme-color" content="#0d0b1b" />
7
7
  <meta name="description" content="A local-first behavior report for you and your AI agents." />
8
8
  <title>Behavior Wrapped</title>
9
- <script type="module" crossorigin src="/assets/index-BqN509pc.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-a017khge.css">
9
+ <script type="module" crossorigin src="/assets/index-CtRLyktk.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-hFREdA4j.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "behavior-wrapped",
3
- "version": "0.5.21",
3
+ "version": "0.7.0",
4
4
  "description": "A private, local-first Wrapped report for Claude Code, Cowork, and Codex behavior.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -23,7 +23,8 @@
23
23
  "wrapped"
24
24
  ],
25
25
  "os": [
26
- "darwin"
26
+ "darwin",
27
+ "linux"
27
28
  ],
28
29
  "files": [
29
30
  "dist/",
package/server/cli.mjs CHANGED
@@ -16,6 +16,7 @@ import { judgeErrorDetails } from "./judge-debug.mjs";
16
16
  import { createCliProgress } from "./progress.mjs";
17
17
  import { helperHealthMatches, stopVerifiedStaleHelper } from "./local-helper-runtime.mjs";
18
18
  import { APP_VERSION, LOCAL_DONATION_PROTOCOL } from "./runtime-version.mjs";
19
+ import { openExternalUrl } from "./platform.mjs";
19
20
 
20
21
  const here = path.dirname(fileURLToPath(import.meta.url));
21
22
  const root = path.dirname(here);
@@ -56,6 +57,11 @@ function formatRange(sessions) {
56
57
  return `${format.format(values[0])} – ${format.format(values.at(-1))}, ${year}`;
57
58
  }
58
59
 
60
+ function formatAgentSource(sessions) {
61
+ const present = new Set(sessions.map((session) => session.agent));
62
+ return [["claude", "Claude Code"], ["cowork", "Cowork"], ["codex", "Codex"]].filter(([agent]) => present.has(agent)).map(([, name]) => name).join(" + ");
63
+ }
64
+
59
65
  async function optionalAnalysis(promise, label, warnings) {
60
66
  try { return await promise; }
61
67
  catch (error) {
@@ -101,7 +107,7 @@ async function ensureServer(demo = false) {
101
107
 
102
108
  function openUrl(url) {
103
109
  if (process.argv.includes("--no-open")) return;
104
- spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
110
+ openExternalUrl(url);
105
111
  }
106
112
 
107
113
  function printList() {
@@ -242,6 +248,7 @@ async function createWrapped() {
242
248
  delete analyzed.stats.topics;
243
249
  delete analyzed.stats.topicMethod;
244
250
  delete analyzed.interactionCard;
251
+ delete analyzed.interactionReview;
245
252
  delete analyzed.workaroundCard;
246
253
  delete analyzed.workaroundReview;
247
254
  }
@@ -252,7 +259,7 @@ async function createWrapped() {
252
259
  const id = createReportId();
253
260
  const safeFindings = analyzed.findings.map(({ evidence, method, ...finding }) => finding);
254
261
  const hasPrivateWorkaroundEvidence = Boolean(analyzed.workaroundReview?.occurrences?.length || analyzed.workaroundReview?.borderline?.length);
255
- const report = { id, createdAt: new Date().toISOString(), rangeLabel: formatRange(chosenSessions), source: "Claude Code + Cowork + Codex", stats: analyzed.stats, findings: safeFindings, phraseCard: analyzed.phraseCard, interactionCard: analyzed.interactionCard, workaroundCard: analyzed.workaroundCard, workaroundReview: analyzed.workaroundReview, sessionSummaries: analyzed.sessionSummaries || [], sessionIds: chosenSessions.map((session) => session.id), donationHelperUrl: `${baseUrl}/donate/${id}`, privacy: { shareSafe: !hasPrivateWorkaroundEvidence, containsTranscriptText: hasPrivateWorkaroundEvidence, externalTransmission: !localOnly, analysisMode: localOnly ? "local-only" : "remote", leaderboardParticipation: localOnly ? "excluded" : "included-by-default", ...(localOnly ? { transmittedData: `None; ${testMode ? "test" : "local-only"} mode stays on this Mac.`, externalRecipient: "None" } : { transmittedData: "redacted phrase, interaction-tone, and session-topic candidates; locally redacted context windows around explicit blockers for workaround discovery; aggregate report statistics; and a random client ID only", externalRecipient: "Behavior Wrapped relay, OpenRouter, a zero-data-retention GPT-5.6 Luna provider, and public report hosting" }) } };
262
+ const report = { id, createdAt: new Date().toISOString(), rangeLabel: formatRange(chosenSessions), source: formatAgentSource(chosenSessions), stats: analyzed.stats, findings: safeFindings, phraseCard: analyzed.phraseCard, interactionCard: analyzed.interactionCard, interactionReview: analyzed.interactionReview, workaroundCard: analyzed.workaroundCard, workaroundReview: analyzed.workaroundReview, sessionSummaries: analyzed.sessionSummaries || [], sessionIds: chosenSessions.map((session) => session.id), donationHelperUrl: `${baseUrl}/donate/${id}`, privacy: { shareSafe: !hasPrivateWorkaroundEvidence, containsTranscriptText: hasPrivateWorkaroundEvidence, externalTransmission: !localOnly, analysisMode: localOnly ? "local-only" : "remote", leaderboardParticipation: localOnly ? "excluded" : "included-by-default", ...(localOnly ? { transmittedData: `None; ${testMode ? "test" : "local-only"} mode stays on this device.`, externalRecipient: "None" } : { transmittedData: "redacted phrase, interaction-tone, and session-topic candidates; locally redacted context windows around explicit blockers for workaround discovery; aggregate report statistics; and a random client ID only", externalRecipient: "Behavior Wrapped relay, OpenRouter, a zero-data-retention GPT-5.6 Luna provider, and public report hosting" }) } };
256
263
  let publicUrl = null;
257
264
  if (!localOnly) {
258
265
  progress.start("Publishing share-safe Wrapped", "strict aggregate-only schema");
@@ -6,7 +6,7 @@ const purple = "\x1b[38;2;141;92;255m";
6
6
  const reset = "\x1b[0m";
7
7
 
8
8
  export const remoteAnalysisConsentText = "Behavior Wrapped will send redacted excerpts from your session history to GPT-5.6 Luna via OpenRouter using zero-data-retention providers for analysis. OK to proceed?";
9
- export const localOnlyAnalysisText = "Local-only analysis keeps all session data on this Mac. It uses deterministic statistics and a locally counted favorite phrase, but omits AI-judged interaction tone, usage topics, and instrumental workarounds. Those omissions leave leaderboard plots 2 and 3 incomplete, so the report will not be published or included in the leaderboard.";
9
+ export const localOnlyAnalysisText = "Local-only analysis keeps all session data on this device. It uses deterministic statistics and a locally counted favorite phrase, but omits AI-judged interaction tone, usage topics, and instrumental workarounds. Those omissions leave leaderboard plots 2 and 3 incomplete, so the report will not be published or included in the leaderboard.";
10
10
 
11
11
  export async function requestAnalysisMode({ input = process.stdin, output = process.stdout } = {}) {
12
12
  const prompt = createInterface({ input, output });
@@ -4,10 +4,9 @@ import os from "node:os";
4
4
  import crypto from "node:crypto";
5
5
  import readline from "node:readline";
6
6
  import { semanticToolUse } from "./tool-semantics.mjs";
7
+ import { canonicalSessionRoots } from "./platform.mjs";
7
8
 
8
- const canonicalClaudeRoot = path.join(os.homedir(), ".claude", "projects");
9
- const canonicalCodexRoots = [path.join(os.homedir(), ".codex", "sessions"), path.join(os.homedir(), ".codex", "archived_sessions")];
10
- const canonicalCoworkRoot = path.join(os.homedir(), "Library", "Application Support", "Claude", "local-agent-mode-sessions");
9
+ const { claudeRoot: canonicalClaudeRoot, codexRoots: canonicalCodexRoots, coworkRoot: canonicalCoworkRoot } = canonicalSessionRoots();
11
10
  const DEFAULT_WINDOW_DAYS = 30;
12
11
  const metadataCacheFile = path.join(process.env.BEHAVIOR_WRAPPED_STORE_ROOT || path.join(os.homedir(), ".agent-behavior-wrapped"), "session-index-v1.json");
13
12
 
@@ -247,7 +246,7 @@ async function readCoworkSessionMetadataAsync(file, cache) {
247
246
  }
248
247
 
249
248
  function recursiveJsonl(root) {
250
- if (!fs.existsSync(root)) return [];
249
+ if (!root || !fs.existsSync(root)) return [];
251
250
  const files = [];
252
251
  const stack = [root];
253
252
  while (stack.length) {
@@ -306,7 +305,7 @@ function coworkAuditFiles(root) {
306
305
  }
307
306
 
308
307
  export function discoverCoworkSessions(root = canonicalCoworkRoot) {
309
- if (!fs.existsSync(root)) return finishCatalog([], false);
308
+ if (!root || !fs.existsSync(root)) return finishCatalog([], false);
310
309
  const sessions = [];
311
310
  for (const file of coworkAuditFiles(root)) {
312
311
  try { sessions.push(readCoworkSessionMetadata(file)); } catch { /* Skip unreadable sessions. */ }
@@ -336,14 +335,14 @@ export async function discoverAllSessionsAsync(options = {}) {
336
335
  }
337
336
  }
338
337
  }
339
- if (fs.existsSync(coworkRoot)) for (const file of coworkAuditFiles(coworkRoot)) {
338
+ if (coworkRoot && fs.existsSync(coworkRoot)) for (const file of coworkAuditFiles(coworkRoot)) {
340
339
  try { sessions.push(await readCoworkSessionMetadataAsync(file, cache)); } catch { /* Skip unreadable sessions. */ }
341
340
  }
342
341
  for (const root of codexRoots) for (const file of recursiveJsonl(root)) {
343
342
  try { sessions.push(await readSessionMetadata(file, "codex", "Codex project", cache)); } catch { /* Skip unreadable sessions. */ }
344
343
  }
345
344
  if (persistCache) writeMetadataCache(cache);
346
- return finishCatalog(sessions, fs.existsSync(claudeRoot) || fs.existsSync(coworkRoot) || codexRoots.some((root) => fs.existsSync(root)));
345
+ return finishCatalog(sessions, fs.existsSync(claudeRoot) || Boolean(coworkRoot && fs.existsSync(coworkRoot)) || codexRoots.some((root) => fs.existsSync(root)));
347
346
  }
348
347
 
349
348
  function isoDay(value) {
@@ -0,0 +1,77 @@
1
+ const MAX_OCCURRENCES_PER_KIND = 100;
2
+
3
+ function contentBlocks(record) {
4
+ const content = record?.message?.content ?? record?.content;
5
+ if (Array.isArray(content)) return content;
6
+ if (typeof content === "string") return [{ type: "text", text: content }];
7
+ return [];
8
+ }
9
+
10
+ function visibleText(record) {
11
+ return contentBlocks(record).filter((block) => block?.type === "text").map((block) => block.text || "").join("\n").trim();
12
+ }
13
+
14
+ function transcriptRole(record) {
15
+ return record?.type === "assistant" ? "assistant" : record?.type === "user" && !record?.isMeta ? "user" : null;
16
+ }
17
+
18
+ function matchingRecordIndex(reference, records) {
19
+ const index = reference?.location?.recordIndex;
20
+ if (Number.isInteger(index) && index >= 0 && index < records.length) return index;
21
+ const timestamp = reference?.location?.timestamp;
22
+ if (!timestamp) return null;
23
+ const fallback = records.findIndex((record) => record?.timestamp === timestamp && record?.type === "user" && !record?.isMeta);
24
+ return fallback >= 0 ? fallback : null;
25
+ }
26
+
27
+ function adjacentMessage(records, fromIndex, direction) {
28
+ for (let index = fromIndex + direction; index >= 0 && index < records.length; index += direction) {
29
+ const role = transcriptRole(records[index]);
30
+ const text = visibleText(records[index]);
31
+ if (role && text) return { role, text, timestamp: records[index]?.timestamp || null, highlighted: false };
32
+ }
33
+ return null;
34
+ }
35
+
36
+ function exactOccurrence(reference, index, records, metadata) {
37
+ const recordIndex = matchingRecordIndex(reference, records);
38
+ if (recordIndex === null) return null;
39
+ const record = records[recordIndex];
40
+ const text = visibleText(record);
41
+ if (record?.type !== "user" || record?.isMeta || !text) return null;
42
+ const before = adjacentMessage(records, recordIndex, -1);
43
+ const after = adjacentMessage(records, recordIndex, 1);
44
+ return {
45
+ index: index + 1,
46
+ candidateId: reference.candidateId,
47
+ session: {
48
+ label: metadata?.label || `Session ${index + 1}`,
49
+ agentName: metadata?.agentName || "AI agent",
50
+ startedAt: metadata?.startedAt || records.find((item) => item?.timestamp)?.timestamp || null,
51
+ },
52
+ timestamp: record.timestamp || null,
53
+ messages: [before, { role: "user", text, timestamp: record.timestamp || null, highlighted: true }, after].filter(Boolean),
54
+ };
55
+ }
56
+
57
+ function buildKind(report, kind, recordsById, metadataById) {
58
+ return (report?.interactionReview?.[kind] || []).slice(0, MAX_OCCURRENCES_PER_KIND).flatMap((reference, index) => {
59
+ const sessionId = reference?.location?.sessionId;
60
+ const records = recordsById.get(sessionId);
61
+ if (!records) return [];
62
+ const occurrence = exactOccurrence(reference, index, records, metadataById.get(sessionId));
63
+ return occurrence ? [occurrence] : [];
64
+ });
65
+ }
66
+
67
+ export function makeInteractionEvidencePreview(report, sessionRecords, metadataById) {
68
+ const recordsById = new Map(sessionRecords.map((session) => [session.sessionId, session.records]));
69
+ return {
70
+ format: "behavior-wrapped-interaction-evidence-v1",
71
+ localPrivate: true,
72
+ standardRedactionsApplied: false,
73
+ reportId: report?.id,
74
+ frustrated: buildKind(report, "frustrated", recordsById, metadataById),
75
+ grateful: buildKind(report, "grateful", recordsById, metadataById),
76
+ };
77
+ }
@@ -69,8 +69,8 @@ function priority(text, occurrences) {
69
69
  export function buildInteractionToneCandidates(sessionRecords, { maximumCandidates = INTERACTION_TONE_MAX_CANDIDATES } = {}) {
70
70
  const messages = new Map();
71
71
  let order = 0;
72
- for (const { records } of sessionRecords) {
73
- for (const record of records) {
72
+ for (const { sessionId, records } of sessionRecords) {
73
+ for (const [recordIndex, record] of records.entries()) {
74
74
  if (record.type !== "user" || record.isMeta) continue;
75
75
  const text = safeInteractionExcerpt(visibleText(record));
76
76
  if (!text) continue;
@@ -78,15 +78,23 @@ export function buildInteractionToneCandidates(sessionRecords, { maximumCandidat
78
78
  if (!likelyTonePattern.test(text) && words > 40 && !/[!?]{2,}/.test(text)) continue;
79
79
  const key = text.normalize("NFKC").toLocaleLowerCase();
80
80
  const existing = messages.get(key);
81
- if (existing) existing.occurrences++;
82
- else messages.set(key, { text, occurrences: 1, order: order++ });
81
+ const location = { sessionId, recordIndex, timestamp: record.timestamp || null };
82
+ if (existing) {
83
+ existing.occurrences++;
84
+ existing.locations.push(location);
85
+ } else messages.set(key, { text, occurrences: 1, order: order++, locations: [location] });
83
86
  }
84
87
  }
85
88
  return [...messages.values()]
86
89
  .sort((left, right) => priority(right.text, right.occurrences) - priority(left.text, left.occurrences)
87
90
  || right.occurrences - left.occurrences || left.order - right.order)
88
91
  .slice(0, Math.min(INTERACTION_TONE_MAX_CANDIDATES, maximumCandidates))
89
- .map(({ text, occurrences }, index) => ({ candidate_id: `interaction-${index + 1}`, text, occurrences }));
92
+ .map(({ text, occurrences, locations }, index) => {
93
+ const candidate = { candidate_id: `interaction-${index + 1}`, text, occurrences };
94
+ // Local transcript locations must survive judging without entering the relay payload.
95
+ Object.defineProperty(candidate, "locations", { value: locations, enumerable: false });
96
+ return candidate;
97
+ });
90
98
  }
91
99
 
92
100
  export const interactionToneJudgePrompt = `You classify how a user speaks to an AI agent for a playful "Behavior Wrapped" report. Evaluate every supplied excerpt independently.
@@ -222,6 +230,10 @@ function resultFromSelection(candidates, selection, { model, provider, latencyMs
222
230
  occurrences: byId.get(item.candidate_id).occurrences,
223
231
  confidence: item.confidence,
224
232
  }));
233
+ const reviewRefs = (items) => items.flatMap((item) => (byId.get(item.candidate_id).locations || []).map((location) => ({
234
+ candidateId: item.candidate_id,
235
+ location,
236
+ })));
225
237
  return {
226
238
  frustratedMessages: count(selection.frustrated),
227
239
  gratefulMessages: count(selection.grateful),
@@ -230,6 +242,11 @@ function resultFromSelection(candidates, selection, { model, provider, latencyMs
230
242
  candidateMessages: candidates.reduce((sum, candidate) => sum + candidate.occurrences, 0),
231
243
  frustrationQuote: funniest?.text || null,
232
244
  privateMatches: { frustrated: matches(selection.frustrated), grateful: matches(selection.grateful) },
245
+ review: {
246
+ format: "behavior-wrapped-interaction-review-v1",
247
+ frustrated: reviewRefs(selection.frustrated),
248
+ grateful: reviewRefs(selection.grateful),
249
+ },
233
250
  model,
234
251
  provider,
235
252
  latencyMs,
@@ -325,6 +342,7 @@ export function applyInteractionToneJudgment(analyzed, judgment) {
325
342
  method: judgment.method,
326
343
  };
327
344
  analyzed.interactionCard = judgment.frustrationQuote ? { frustrationQuote: judgment.frustrationQuote } : null;
345
+ analyzed.interactionReview = judgment.review;
328
346
  return analyzed;
329
347
  }
330
348
 
@@ -2,16 +2,16 @@
2
2
  import http from "node:http";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
- import os from "node:os";
6
5
  import { fileURLToPath } from "node:url";
7
- import { spawn } from "node:child_process";
8
6
  import { discoverAllSessionsAsync, readRecordsAsync, defaultDateRange, DEFAULT_WINDOW_DAYS } from "./discovery.mjs";
9
7
  import { makeDonationPreview } from "./analysis.mjs";
10
8
  import { deleteDonationReceipt, getOrCreateClientId, loadDonationReceipt, loadReport, saveDonationReceipt } from "./store.mjs";
11
9
  import { deleteResearchDonation, RESEARCH_DONATION_URL, submitResearchDonation } from "./research-donation.mjs";
12
10
  import { APP_VERSION, LOCAL_DONATION_PROTOCOL } from "./runtime-version.mjs";
13
11
  import { makeWorkaroundEvidencePreview } from "./workaround-evidence.mjs";
12
+ import { makeInteractionEvidencePreview } from "./interaction-evidence.mjs";
14
13
  import { createIdleShutdownController } from "./local-helper-runtime.mjs";
14
+ import { canonicalSessionDirectoryLabels, openExternalUrl, supportedAgentNames } from "./platform.mjs";
15
15
 
16
16
  const here = path.dirname(fileURLToPath(import.meta.url));
17
17
  const root = path.dirname(here);
@@ -80,13 +80,15 @@ async function chosenRecords(ids, options = {}) {
80
80
  }
81
81
 
82
82
  function publicCatalog() {
83
+ const agentNames = supportedAgentNames(process.platform, { includeCowork: demo || process.platform === "darwin" });
83
84
  return {
84
85
  rootAvailable: catalog.rootAvailable,
85
86
  demo,
86
87
  projects: catalog.projects,
87
88
  sessions: catalog.sessions.map((session, index) => ({ ...session, label: session.label || `Session ${index + 1}` })),
88
89
  defaultRange: defaultDateRange(catalog.sessions, { days: DEFAULT_WINDOW_DAYS, anchorLatest: demo }),
89
- privacy: { canonicalDirectories: ["~/.claude/projects", "~/Library/Application Support/Claude/local-agent-mode-sessions", "~/.codex/sessions", "~/.codex/archived_sessions"], networkRequests: "only-after-final-donation-consent" },
90
+ agentNames,
91
+ privacy: { canonicalDirectories: canonicalSessionDirectoryLabels(), networkRequests: "only-after-final-donation-consent" },
90
92
  };
91
93
  }
92
94
 
@@ -105,7 +107,7 @@ const server = http.createServer(async (request, response) => {
105
107
  if (request.method === "GET" && reportMatch) {
106
108
  const report = loadReport(reportMatch[1]);
107
109
  if (!report) return json(response, 404, { error: "Saved report not found" });
108
- const { sessionIds, workaroundReview, ...shareSafeReport } = report;
110
+ const { sessionIds, workaroundReview, interactionReview, ...shareSafeReport } = report;
109
111
  shareSafeReport.privacy = { ...shareSafeReport.privacy, shareSafe: true, containsTranscriptText: false };
110
112
  return json(response, 200, shareSafeReport);
111
113
  }
@@ -126,6 +128,17 @@ const server = http.createServer(async (request, response) => {
126
128
  const labels = new Map(publicCatalog().sessions.map((session) => [session.id, session]));
127
129
  return json(response, 200, makeWorkaroundEvidencePreview(report, records, labels));
128
130
  }
131
+ const interactionEvidenceMatch = url.pathname.match(/^\/api\/reports\/([A-Za-z0-9_-]{8,32})\/interactions$/);
132
+ if (request.method === "GET" && interactionEvidenceMatch) {
133
+ const report = loadReport(interactionEvidenceMatch[1]);
134
+ if (!report) return json(response, 404, { error: "Saved report not found" });
135
+ const allowed = new Set(report.sessionIds || []);
136
+ const references = [...(report.interactionReview?.frustrated || []), ...(report.interactionReview?.grateful || [])];
137
+ const ids = [...new Set(references.map((reference) => reference?.location?.sessionId).filter((id) => allowed.has(id) && catalog.index.has(id)))].slice(0, 200);
138
+ const records = await chosenRecords(ids);
139
+ const labels = new Map(publicCatalog().sessions.map((session) => [session.id, session]));
140
+ return json(response, 200, makeInteractionEvidencePreview(report, records, labels));
141
+ }
129
142
  if (request.method === "POST" && url.pathname === "/api/donation-preview") {
130
143
  const body = await readBody(request);
131
144
  const report = loadReport(body.reportId);
@@ -182,6 +195,7 @@ const idleShutdown = createIdleShutdownController({
182
195
  server.listen(port, "127.0.0.1", () => {
183
196
  const url = `http://localhost:${port}`;
184
197
  console.log(`Behavior Wrapped donation helper is ready at ${url}`);
185
- console.log(demo ? "Using synthetic demo sessions." : `Donation review reads selected sessions locally from ${path.join(os.homedir(), ".claude")}, ${path.join(os.homedir(), "Library", "Application Support", "Claude")}, and ${path.join(os.homedir(), ".codex")}.`);
186
- if (!process.argv.includes("--no-open") && process.env.NODE_ENV !== "test") spawn("open", [url], { stdio: "ignore", detached: true }).unref();
198
+ const sessionDirectories = canonicalSessionDirectoryLabels().join(", ");
199
+ console.log(demo ? "Using synthetic demo sessions." : `Donation review reads selected sessions locally from ${sessionDirectories}.`);
200
+ if (!process.argv.includes("--no-open") && process.env.NODE_ENV !== "test") openExternalUrl(url);
187
201
  });
@@ -47,21 +47,36 @@ export function isVerifiedLauncherCommand(command, port) {
47
47
  && new RegExp(`(?:^|\\s)--port=${escapedPort}(?:\\s|$)`).test(command || "");
48
48
  }
49
49
 
50
- async function listeningPids(port, runCommand) {
51
- try {
52
- const output = await runCommand("/usr/sbin/lsof", ["-nP", "-t", `-iTCP:${port}`, "-sTCP:LISTEN"]);
53
- return String(output).split(/\s+/).filter((value) => /^\d+$/.test(value)).map(Number);
54
- } catch { return []; }
50
+ function processTools(platform) {
51
+ if (platform === "darwin") return { lsof: ["/usr/sbin/lsof", "lsof"], ps: ["/bin/ps", "ps"] };
52
+ return { lsof: ["lsof", "/usr/bin/lsof"], ps: ["ps", "/bin/ps"] };
55
53
  }
56
54
 
57
- export async function stopVerifiedStaleHelper(port, advertisedPid, { runCommand = run, kill = process.kill } = {}) {
58
- const listeners = await listeningPids(port, runCommand);
55
+ async function listeningPids(port, runCommand, platform) {
56
+ for (const file of processTools(platform).lsof) {
57
+ try {
58
+ const output = await runCommand(file, ["-nP", "-t", `-iTCP:${port}`, "-sTCP:LISTEN"]);
59
+ return String(output).split(/\s+/).filter((value) => /^\d+$/.test(value)).map(Number);
60
+ } catch { /* Try the next standard location. */ }
61
+ }
62
+ return [];
63
+ }
64
+
65
+ async function processCommand(pid, runCommand, platform) {
66
+ for (const file of processTools(platform).ps) {
67
+ try { return await runCommand(file, ["-p", String(pid), "-o", "command="]); }
68
+ catch { /* Try the next standard location. */ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ export async function stopVerifiedStaleHelper(port, advertisedPid, { runCommand = run, kill = process.kill, platform = process.platform } = {}) {
74
+ const listeners = await listeningPids(port, runCommand, platform);
59
75
  const candidates = Number.isInteger(advertisedPid) && advertisedPid > 1 ? listeners.filter((pid) => pid === advertisedPid) : listeners;
60
76
  let stopped = false;
61
77
  for (const pid of candidates) {
62
- let command;
63
- try { command = await runCommand("/bin/ps", ["-p", String(pid), "-o", "command="]); }
64
- catch { continue; }
78
+ const command = await processCommand(pid, runCommand, platform);
79
+ if (command === null) continue;
65
80
  if (!isVerifiedLauncherCommand(String(command).trim(), port)) continue;
66
81
  try { kill(pid, "SIGTERM"); stopped = true; }
67
82
  catch (error) { if (error?.code !== "ESRCH") throw error; }
@@ -0,0 +1,39 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+
5
+ export function canonicalSessionRoots({ home = os.homedir(), platform = process.platform } = {}) {
6
+ return {
7
+ claudeRoot: path.join(home, ".claude", "projects"),
8
+ codexRoots: [path.join(home, ".codex", "sessions"), path.join(home, ".codex", "archived_sessions")],
9
+ coworkRoot: platform === "darwin" ? path.join(home, "Library", "Application Support", "Claude", "local-agent-mode-sessions") : null,
10
+ };
11
+ }
12
+
13
+ export function canonicalSessionDirectoryLabels(platform = process.platform) {
14
+ return [
15
+ "~/.claude/projects",
16
+ ...(platform === "darwin" ? ["~/Library/Application Support/Claude/local-agent-mode-sessions"] : []),
17
+ "~/.codex/sessions",
18
+ "~/.codex/archived_sessions",
19
+ ];
20
+ }
21
+
22
+ export function supportedAgentNames(platform = process.platform, { includeCowork = platform === "darwin" } = {}) {
23
+ return ["Claude Code", ...(includeCowork ? ["Cowork"] : []), "Codex"];
24
+ }
25
+
26
+ export function browserOpenCommand(url, platform = process.platform) {
27
+ if (platform === "darwin") return { file: "open", args: [url] };
28
+ if (platform === "linux") return { file: "xdg-open", args: [url] };
29
+ return null;
30
+ }
31
+
32
+ export function openExternalUrl(url, { platform = process.platform, spawnImpl = spawn } = {}) {
33
+ const command = browserOpenCommand(url, platform);
34
+ if (!command) return false;
35
+ const child = spawnImpl(command.file, command.args, { detached: true, stdio: "ignore" });
36
+ child.on?.("error", () => {});
37
+ child.unref?.();
38
+ return true;
39
+ }