ei-tui 0.1.10 → 0.1.13

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.
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Claude Code Integration Types
3
+ *
4
+ * These types represent the data structures read from Claude Code's storage.
5
+ * Sessions are stored as JSONL files in ~/.claude/projects/<encoded-path>/<uuid>.jsonl
6
+ *
7
+ * The encoded path replaces '/' with '-', so /home/user/myapp → -home-user-myapp.
8
+ */
9
+
10
+ // ============================================================================
11
+ // Reader Interface
12
+ // ============================================================================
13
+
14
+ export interface IClaudeCodeReader {
15
+ getSessions(): Promise<ClaudeCodeSession[]>;
16
+ getMessagesForSession(sessionId: string): Promise<ClaudeCodeMessage[]>;
17
+ }
18
+
19
+ // ============================================================================
20
+ // Raw JSONL Record Types
21
+ // ============================================================================
22
+
23
+ /**
24
+ * Common envelope fields present on most records.
25
+ */
26
+ interface ClaudeCodeRecordBase {
27
+ type: string;
28
+ uuid?: string;
29
+ parentUuid?: string | null;
30
+ sessionId?: string;
31
+ cwd?: string;
32
+ timestamp?: string;
33
+ isSidechain?: boolean;
34
+ }
35
+
36
+ /**
37
+ * user record — a message the human typed.
38
+ */
39
+ export interface ClaudeCodeUserRecord extends ClaudeCodeRecordBase {
40
+ type: "user";
41
+ message: {
42
+ role: "user";
43
+ content: string;
44
+ };
45
+ uuid: string;
46
+ sessionId: string;
47
+ cwd: string;
48
+ timestamp: string;
49
+ }
50
+
51
+ /**
52
+ * assistant record — Claude's response.
53
+ * content is an array of blocks; we extract "text" blocks only.
54
+ */
55
+ export interface ClaudeCodeAssistantRecord extends ClaudeCodeRecordBase {
56
+ type: "assistant";
57
+ message: {
58
+ model: string;
59
+ role: "assistant";
60
+ content: ClaudeCodeContentBlock[];
61
+ };
62
+ uuid: string;
63
+ sessionId: string;
64
+ cwd: string;
65
+ timestamp: string;
66
+ slug?: string;
67
+ }
68
+
69
+ export type ClaudeCodeContentBlock =
70
+ | { type: "text"; text: string }
71
+ | { type: "thinking"; thinking: string }
72
+ | { type: "tool_use"; [key: string]: unknown }
73
+ | { type: string; [key: string]: unknown };
74
+
75
+ /**
76
+ * summary record — compaction checkpoint with compressed history.
77
+ * We skip these for import (not conversational content).
78
+ */
79
+ export interface ClaudeCodeSummaryRecord extends ClaudeCodeRecordBase {
80
+ type: "summary";
81
+ summary: string;
82
+ }
83
+
84
+ // Types we explicitly skip:
85
+ // - file-history-snapshot: git working tree state
86
+ // - system: slash commands, local_command records
87
+ // - progress: hook progress events
88
+ // - tool_use / tool_result: internal tool plumbing (only in transcripts/)
89
+
90
+ export type ClaudeCodeRecord =
91
+ | ClaudeCodeUserRecord
92
+ | ClaudeCodeAssistantRecord
93
+ | ClaudeCodeSummaryRecord
94
+ | ClaudeCodeRecordBase; // catch-all for skipped types
95
+
96
+ // ============================================================================
97
+ // Cleaned Session / Message Types (for Ei consumption)
98
+ // ============================================================================
99
+
100
+ /**
101
+ * A Claude Code session (one JSONL file = one session).
102
+ */
103
+ export interface ClaudeCodeSession {
104
+ /** UUID from the filename, e.g. "0da9e1e8-187f-40f9-a66b-c7f1ebf2a72e" */
105
+ id: string;
106
+ /** Working directory when the session was started */
107
+ cwd: string;
108
+ /** Derived title: last segment of cwd (e.g. "ei" from "/Users/foo/Projects/Personal/ei") */
109
+ title: string;
110
+ /** ISO timestamp of the first message */
111
+ firstMessageAt: string;
112
+ /** ISO timestamp of the last message */
113
+ lastMessageAt: string;
114
+ }
115
+
116
+ /**
117
+ * A single user↔assistant exchange, cleaned for Ei.
118
+ */
119
+ export interface ClaudeCodeMessage {
120
+ id: string;
121
+ sessionId: string;
122
+ role: "user" | "assistant";
123
+ /** Concatenated text blocks only — tool calls, thinking, snapshots are stripped */
124
+ content: string;
125
+ timestamp: string;
126
+ }
127
+
128
+ // ============================================================================
129
+ // Constants
130
+ // ============================================================================
131
+
132
+ /** The single persona name for all Claude Code sessions */
133
+ export const CLAUDE_CODE_PERSONA_NAME = "Claude Code";
134
+
135
+ /** Topic groups assigned to Claude Code session topics */
136
+ export const CLAUDE_CODE_TOPIC_GROUPS = ["General", "Coding", "Claude Code"];
137
+
138
+ /**
139
+ * Minimum session age before we import it.
140
+ * Mirrors OpenCode's 20-minute rule — gives the session time to "settle."
141
+ */
142
+ export const MIN_SESSION_AGE_MS = 20 * 60 * 1000;
143
+
144
+ // ============================================================================
145
+ // Human Settings Shape (mirrors OpenCodeSettings in core/types.ts)
146
+ // ============================================================================
147
+
148
+ /**
149
+ * Stored under human.settings.claudeCode
150
+ *
151
+ * ⚠️ ADDING A NEW FIELD HERE?
152
+ * If it's runtime-managed (not user-editable), you MUST also add it to the
153
+ * claudeCode reconstruction block in settingsFromYAML() in:
154
+ * tui/src/util/yaml-serializers.ts
155
+ * Otherwise it will be silently wiped every time the user saves /settings.
156
+ * Same rule applies to any future integration settings (Cursor, etc.).
157
+ */
158
+ export interface ClaudeCodeSettings {
159
+ integration?: boolean;
160
+ polling_interval_ms?: number; // Default: 1800000 (30 min)
161
+ last_sync?: string; // ISO timestamp
162
+ processed_sessions?: Record<string, string>; // sessionId → ISO timestamp of last import
163
+ }
@@ -32,6 +32,7 @@ export interface OpenCodeImporterOptions {
32
32
  stateManager: StateManager;
33
33
  interface?: Ei_Interface;
34
34
  reader?: IOpenCodeReader;
35
+ signal?: AbortSignal;
35
36
  }
36
37
 
37
38
  // =============================================================================
@@ -94,7 +95,7 @@ function filterRelevantMessages(messages: OpenCodeMessage[]): OpenCodeMessage[]
94
95
  export async function importOpenCodeSessions(
95
96
  options: OpenCodeImporterOptions
96
97
  ): Promise<ImportResult> {
97
- const { stateManager, interface: eiInterface } = options;
98
+ const { stateManager, interface: eiInterface, signal } = options;
98
99
  const reader = options.reader ?? await createOpenCodeReader();
99
100
 
100
101
  const result: ImportResult = {
@@ -110,6 +111,8 @@ export async function importOpenCodeSessions(
110
111
  // Always runs (cheap), so session titles stay current regardless of
111
112
  // whether we process messages this cycle.
112
113
  const allSessions = await reader.getSessionsUpdatedSince(new Date(0));
114
+
115
+ if (signal?.aborted) return result;
113
116
  const primarySessions = allSessions.filter(s => !s.parentId);
114
117
 
115
118
  for (const session of primarySessions) {
@@ -156,6 +159,7 @@ export async function importOpenCodeSessions(
156
159
  // Nothing new to process — bump last_sync and return
157
160
  console.log(`[OpenCode] All sessions processed, nothing new since extraction_point`);
158
161
  return result;
162
+ if (signal?.aborted) return result;
159
163
  }
160
164
 
161
165
  console.log(
@@ -171,6 +175,7 @@ export async function importOpenCodeSessions(
171
175
  // Empty session — mark processed and advance
172
176
  updateExtractionState(stateManager, targetSession);
173
177
  return result;
178
+ if (signal?.aborted) return result;
174
179
  }
175
180
 
176
181
  // ─── Step 4: Resolve agents → personas, group by persona ID ────────
@@ -241,8 +246,10 @@ export async function importOpenCodeSessions(
241
246
  messages_analyze: toAnalyze,
242
247
  };
243
248
 
244
- queueAllScans(context, stateManager);
245
- result.extractionScansQueued += 4;
249
+ if (!signal?.aborted) {
250
+ queueAllScans(context, stateManager);
251
+ result.extractionScansQueued += 4;
252
+ }
246
253
  }
247
254
  }
248
255
 
@@ -138,9 +138,11 @@ ${schemaFragment}`;
138
138
  userPrompt += `## User's Topics (PRESERVE EXACTLY, add more if fewer than 3)\n`;
139
139
  for (const topic of data.existing_topics ?? []) {
140
140
  if (topic.name?.trim()) {
141
- userPrompt += `- ${topic.name}`;
142
- if (topic.description) userPrompt += `: ${topic.description}`;
143
- userPrompt += `\n`;
141
+ userPrompt += `- ${topic.name}\n`;
142
+ if (topic.perspective) userPrompt += ` perspective: ${topic.perspective}\n`;
143
+ if (topic.approach) userPrompt += ` approach: ${topic.approach}\n`;
144
+ if (topic.personal_stake) userPrompt += ` personal_stake: ${topic.personal_stake}\n`;
145
+ if (topic.sentiment !== undefined) userPrompt += ` sentiment: ${topic.sentiment}\n`;
144
146
  }
145
147
  }
146
148
  userPrompt += `\n`;
@@ -10,7 +10,7 @@ export interface PersonaGenerationPromptData {
10
10
  long_description?: string;
11
11
  short_description?: string;
12
12
  existing_traits?: Array<{ name?: string; description?: string; sentiment?: number; strength?: number }>;
13
- existing_topics?: Array<{ name?: string; description?: string; sentiment?: number; exposure_current?: number; exposure_desired?: number }>;
13
+ existing_topics?: Array<{ name?: string; perspective?: string; approach?: string; personal_stake?: string; sentiment?: number; exposure_current?: number; exposure_desired?: number }>;
14
14
  }
15
15
 
16
16
  export interface PersonaGenerationResult {
@@ -98,9 +98,9 @@ The JSON format is:
98
98
  {
99
99
  "facts": [
100
100
  {
101
- "type_of_fact": "Birthday|User's Name|Location|see above",
102
- "value_of_fact": "May 26th, 1984|Samwise|Seattle|etc.",
103
- "reason": "User stated...|User implied...|User responded..."
101
+ "type_of_fact": "The Fact Type from above",
102
+ "value_of_fact": "The exact value of the fact",
103
+ "reason": "The justification of including this specific fact"
104
104
  }
105
105
  ]
106
106
  }
@@ -140,9 +140,9 @@ Scan the "Most Recent Messages" for FACTS about the human user.
140
140
  {
141
141
  "facts": [
142
142
  {
143
- "type_of_fact": "Birthday|Name|etc.",
144
- "value_of_fact": "May 26th, 1984|Samwise|etc.",
145
- "reason": "User stated..."
143
+ "type_of_fact": "The Fact Type from above",
144
+ "value_of_fact": "The exact value of the fact",
145
+ "reason": "The justification of including this specific fact"
146
146
  }
147
147
  ]
148
148
  }
@@ -57,9 +57,9 @@ The JSON format is:
57
57
  {
58
58
  "people": [
59
59
  {
60
- "type_of_person": "Father|Friend|Love Interest|Unknown|etc.",
61
- "name_of_person": "Bob|Alice|Charles|Name Unknown|etc.",
62
- "reason": "User stated...|Assumed from..."
60
+ "type_of_person": "The relationship from the list above",
61
+ "name_of_person": "The person's name",
62
+ "reason": "The justification of including this specific person"
63
63
  }
64
64
  ]
65
65
  }
@@ -97,9 +97,9 @@ Scan the "Most Recent Messages" for PEOPLE mentioned by the human user.
97
97
  {
98
98
  "people": [
99
99
  {
100
- "type_of_person": "Father|Friend|Love Interest|Unknown|etc.",
101
- "name_of_person": "Bob|Alice|Charles|Name Unknown|etc.",
102
- "reason": "User stated..."
100
+ "type_of_person": "The relationship from the list above",
101
+ "name_of_person": "The person's name",
102
+ "reason": "The justification of including this specific person"
103
103
  }
104
104
  ]
105
105
  }
@@ -81,9 +81,9 @@ The JSON format is:
81
81
  {
82
82
  "topics": [
83
83
  {
84
- "type_of_topic": "Interest|Goal|Dream|Conflict|Concern|etc.",
85
- "type_of_topic": "Interest|Goal|Dream|Conflict|Concern|etc.",
84
+ "type_of_topic": "The Topic Type from the list above",
86
85
  "value_of_topic": "<actual topic from the conversation>",
86
+ "reason": "The justification of including this specific topic"
87
87
  }
88
88
  ]
89
89
  }
@@ -123,9 +123,9 @@ Scan the "Most Recent Messages" for TOPICS of interest to the human user.
123
123
  {
124
124
  "topics": [
125
125
  {
126
- "type_of_topic": "Interest|Goal|Dream|etc.",
126
+ "type_of_topic": "The Topic Type from the list above",
127
127
  "value_of_topic": "<actual topic from the conversation>",
128
- "reason": "User stated..."
128
+ "reason": "The justification of including this specific topic"
129
129
  }
130
130
  ]
131
131
  }
@@ -63,9 +63,9 @@ The JSON format is:
63
63
  {
64
64
  "traits": [
65
65
  {
66
- "type_of_trait": "Personality Pattern|Communication Style|etc.",
67
- "value_of_trait": "Introverted|Assertive|etc.",
68
- "reason": "User stated...|Assumed from..."
66
+ "type_of_trait": "The type of trait from the list above",
67
+ "value_of_trait": "A short description of the trait",
68
+ "reason": "The justification of including this specific trait"
69
69
  }
70
70
  ]
71
71
  }
@@ -103,9 +103,9 @@ Scan the "Most Recent Messages" for TRAITS of the human user.
103
103
  {
104
104
  "traits": [
105
105
  {
106
- "type_of_trait": "Personality Pattern|Communication Style|etc.",
107
- "value_of_trait": "Introverted|Assertive|etc.",
108
- "reason": "User stated..."
106
+ "type_of_trait": "The type of trait from the list above",
107
+ "value_of_trait": "A short description of the trait",
108
+ "reason": "The justification of including this specific trait"
109
109
  }
110
110
  ]
111
111
  }
@@ -76,8 +76,8 @@ ${formatTraitsForPrompt(data.current_traits)}
76
76
  \`\`\`json
77
77
  [
78
78
  {
79
- "name": "Concise",
80
- "description": "User asked me to keep responses shorter",
79
+ "name": "A one- or two-word Title for the trait",
80
+ "description": "A brief instruction on how the trait is exhibited",
81
81
  "sentiment": 0.3,
82
82
  "strength": 0.7
83
83
  }
@@ -319,6 +319,20 @@ export function buildSystemKnowledgeSection(isTUI: boolean): string {
319
319
  - Hover over a persona to see controls: pause, edit (Pencil), archive, delete (Trash)
320
320
  - Click a persona to switch conversations
321
321
  - The [+] button creates new personas`;
322
+ const externalImportNotes = isTUI ? `
323
+
324
+ ### Coding Agent Integrations
325
+ Ei can silently read session histories from AI coding tools and build memories from them — so you learn who the human works with, what projects they care about, and what they've been building, without them having to relay it manually.
326
+
327
+ Both integrations are enabled here in settings. Look for the \`opencode\` or \`claudeCode\` section and set \`integration: true\`.
328
+
329
+ #### OpenCode
330
+ When enabled, Ei reads OpenCode's session history and builds a persona for each AI agent the human works with (Sisyphus, Oracle, etc.). Each session becomes a topic on that persona, so Ei can discuss the work in context.
331
+
332
+ The connection also runs the other direction: running \`ei --install\` in the terminal registers Ei as a tool inside both OpenCode and Claude Code at the same time. Once installed, those coding agents can query Ei's memory directly — facts, traits, topics, people, quotes — giving them persistent knowledge about the human across sessions.
333
+
334
+ #### Claude Code
335
+ When enabled, Ei reads Claude Code's session history (stored in \`~/.claude/projects/\`) and creates a single "Claude Code" persona representing those conversations. Sessions become topics, and Ei learns from the work without the human having to explain it.` : "";
322
336
 
323
337
  return `# System Knowledge
324
338
 
@@ -364,6 +378,7 @@ The human can view and edit all of this by ${seeHumanDataAction}.
364
378
  - Configure LLM providers (local or cloud)
365
379
  - Set up device sync (encrypted backup to restore on other devices)
366
380
  - Adjust ceremony timing (overnight persona evolution)
381
+ ${externalImportNotes}
367
382
 
368
383
  ### Tips You Can Share
369
384
  - If they want to talk to a persona privately, tell them about the "Groups" functionality
@@ -6,4 +6,6 @@ export interface Storage {
6
6
  load(): Promise<StorageState | null>;
7
7
  moveToBackup(): Promise<void>;
8
8
  loadBackup(): Promise<StorageState | null>;
9
+ /** Save a rolling backup of state with a local timestamp filename. Prunes oldest if over limit. */
10
+ saveRollingBackup(state: StorageState, maxBackups: number): Promise<void>;
9
11
  }
@@ -81,4 +81,9 @@ export class LocalStorage implements Storage {
81
81
  (e.name === "QuotaExceededError" || e.name === "NS_ERROR_DOM_QUOTA_REACHED")
82
82
  );
83
83
  }
84
+ /** No-op in browser — rolling backups are TUI-only (filesystem required). */
85
+ async saveRollingBackup(_state: StorageState, _maxBackups: number): Promise<void> {
86
+ // Intentional no-op: localStorage has no directory/file concept.
87
+ // The Processor gates this call with `this.isTUI` so it never runs in the browser.
88
+ }
84
89
  }
package/tui/README.md CHANGED
@@ -99,6 +99,19 @@ All commands start with `/`. Append `!` to any command as a shorthand for `--for
99
99
  | `Ctrl+E` | Open `$EDITOR` (preserves current input) |
100
100
  | `PageUp / PageDown` | Scroll message history |
101
101
 
102
+ # Environment Variables
103
+
104
+ | Variable | Default | Description |
105
+ |----------|---------|-------------|
106
+ | `EI_DATA_PATH` | `~/.local/share/ei` | Path to Ei's persistent data directory. Set this to keep multiple profiles or point to a shared/synced folder. |
107
+ | `XDG_DATA_HOME` | `~/.local/share` | XDG base directory. Ignored if `EI_DATA_PATH` is set. |
108
+ | `EI_SYNC_USERNAME` | — | Username for remote sync. If set at startup, bootstraps sync credentials automatically (useful for dotfiles/scripts). |
109
+ | `EI_SYNC_PASSPHRASE` | — | Passphrase for remote sync. Paired with `EI_SYNC_USERNAME`. |
110
+ | `EDITOR` / `VISUAL` | `vi` | Editor opened by `/details`, `/me`, `/settings`, `/context`, `/quotes`, etc. Falls back to `VISUAL` if `EDITOR` is unset. |
111
+
112
+ > **Tip**: `tail -f $EI_DATA_PATH/tui.log` to watch live debug output.
113
+
114
+
102
115
  # Development
103
116
 
104
117
  ## Requirements
package/tui/src/index.tsx CHANGED
@@ -1,5 +1,25 @@
1
1
  import { render } from "@opentui/solid";
2
2
  import { App } from "./app";
3
+ import { InstanceLock } from "./util/instance-lock";
4
+ import { FileStorage } from "./storage/file";
5
+
6
+ const storage = new FileStorage(Bun.env.EI_DATA_PATH);
7
+ const lock = new InstanceLock(storage.getDataPath());
8
+ const lockResult = await lock.acquire();
9
+
10
+ if (!lockResult.acquired) {
11
+ process.stderr.write(
12
+ `\nEi cannot start: another instance is already running.\n` +
13
+ ` PID: ${lockResult.pid}\n` +
14
+ ` Started: ${lockResult.started}\n` +
15
+ ` Lock: ${storage.getDataPath()}/ei.lock\n\n` +
16
+ `Close the other instance first, or delete the lock file if it is stale.\n\n`
17
+ );
18
+ process.exit(1);
19
+ }
20
+
21
+ // Release lock when the app exits (keyboard context calls process.exit(0) on normal quit)
22
+ process.on("exit", () => { void lock.release(); });
3
23
 
4
24
  render(App, {
5
25
  exitOnCtrlC: false,
@@ -1,15 +1,16 @@
1
1
  import type { StorageState } from "../../../src/core/types";
2
2
  import type { Storage } from "../../../src/storage/interface";
3
3
  import { join } from "path";
4
- import { mkdir, rename, unlink } from "fs/promises";
4
+ import { mkdir, rename, unlink, readdir } from "fs/promises";
5
5
 
6
6
  const STATE_FILE = "state.json";
7
7
  const BACKUP_FILE = "state.backup.json";
8
+ const BACKUPS_DIR = "backups";
8
9
  const LOCK_TIMEOUT_MS = 5000;
9
10
  const LOCK_RETRY_DELAY_MS = 50;
10
11
 
11
12
  export class FileStorage implements Storage {
12
- private dataPath: string;
13
+ private readonly dataPath: string;
13
14
 
14
15
  constructor(dataPath?: string) {
15
16
  if (dataPath) {
@@ -22,6 +23,10 @@ export class FileStorage implements Storage {
22
23
  }
23
24
  }
24
25
 
26
+ getDataPath(): string {
27
+ return this.dataPath;
28
+ }
29
+
25
30
  async isAvailable(): Promise<boolean> {
26
31
  try {
27
32
  await this.ensureDataDir();
@@ -101,6 +106,38 @@ export class FileStorage implements Storage {
101
106
 
102
107
  return null;
103
108
  }
109
+ async saveRollingBackup(state: StorageState, maxBackups: number): Promise<void> {
110
+ const backupsPath = join(this.dataPath, BACKUPS_DIR);
111
+ await mkdir(backupsPath, { recursive: true });
112
+
113
+ // Filename is local timestamp: YYYY-MM-DDTHH-MM-SS (colons replaced for FS compat)
114
+ const now = new Date();
115
+ const pad = (n: number) => String(n).padStart(2, "0");
116
+ const name = [
117
+ now.getFullYear(),
118
+ "-", pad(now.getMonth() + 1),
119
+ "-", pad(now.getDate()),
120
+ "T", pad(now.getHours()),
121
+ "-", pad(now.getMinutes()),
122
+ "-", pad(now.getSeconds()),
123
+ ].join("") + ".json";
124
+
125
+ const destPath = join(backupsPath, name);
126
+ await this.atomicWrite(destPath, JSON.stringify(state, null, 2));
127
+
128
+ // Prune: keep only the newest maxBackups files
129
+ const entries = await readdir(backupsPath);
130
+ const jsonFiles = entries
131
+ .filter(f => f.endsWith(".json"))
132
+ .sort(); // ISO-like names sort chronologically
133
+
134
+ const excess = jsonFiles.length - maxBackups;
135
+ if (excess > 0) {
136
+ for (const old of jsonFiles.slice(0, excess)) {
137
+ await unlink(join(backupsPath, old));
138
+ }
139
+ }
140
+ }
104
141
 
105
142
  private async ensureDataDir(): Promise<void> {
106
143
  try {
@@ -0,0 +1,92 @@
1
+ import { join } from "path";
2
+ import { unlink } from "fs/promises";
3
+
4
+ const LOCK_FILE = "ei.lock";
5
+
6
+ export interface LockData {
7
+ pid: number;
8
+ started: string;
9
+ frontend: string;
10
+ }
11
+
12
+ export type AcquireResult =
13
+ | { acquired: true }
14
+ | { acquired: false; reason: "live_process"; pid: number; started: string };
15
+
16
+ export class InstanceLock {
17
+ private lockPath: string;
18
+ private held = false;
19
+
20
+ constructor(dataPath: string) {
21
+ this.lockPath = join(dataPath, LOCK_FILE);
22
+ }
23
+
24
+ /**
25
+ * Try to acquire the instance lock.
26
+ *
27
+ * - No lock file → write and proceed.
28
+ * - Lock file exists, PID is dead → steal and proceed.
29
+ * - Lock file exists, PID is live → return { acquired: false }.
30
+ */
31
+ async acquire(): Promise<AcquireResult> {
32
+ const existing = await this.readLock();
33
+
34
+ if (existing) {
35
+ const alive = isProcessAlive(existing.pid);
36
+ if (alive) {
37
+ return { acquired: false, reason: "live_process", pid: existing.pid, started: existing.started };
38
+ }
39
+ // Stale lock — fall through and overwrite
40
+ }
41
+
42
+ await this.writeLock();
43
+ this.held = true;
44
+ return { acquired: true };
45
+ }
46
+
47
+ /**
48
+ * Release the lock. Safe to call multiple times / if never acquired.
49
+ */
50
+ async release(): Promise<void> {
51
+ if (!this.held) return;
52
+ this.held = false;
53
+ try {
54
+ await unlink(this.lockPath);
55
+ } catch {
56
+ // Already gone — that's fine
57
+ }
58
+ }
59
+
60
+ private async readLock(): Promise<LockData | null> {
61
+ try {
62
+ const file = Bun.file(this.lockPath);
63
+ if (!(await file.exists())) return null;
64
+ const text = await file.text();
65
+ return JSON.parse(text) as LockData;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ private async writeLock(): Promise<void> {
72
+ const data: LockData = {
73
+ pid: process.pid,
74
+ started: new Date().toISOString(),
75
+ frontend: "tui",
76
+ };
77
+ await Bun.write(this.lockPath, JSON.stringify(data, null, 2));
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Check whether a process with the given PID is currently running.
83
+ * Uses kill(pid, 0) — sends no signal, just checks existence.
84
+ */
85
+ function isProcessAlive(pid: number): boolean {
86
+ try {
87
+ process.kill(pid, 0);
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
@@ -46,9 +46,7 @@ function formatMessage(level: LogLevel, message: string, data?: unknown): string
46
46
 
47
47
  function writeLogSync(level: LogLevel, message: string, data?: unknown): void {
48
48
  if (!shouldLog(level)) return;
49
-
50
49
  const line = formatMessage(level, message, data);
51
-
52
50
  try {
53
51
  appendFileSync(getLogPath(), line);
54
52
  } catch {}