ei-tui 0.1.12 → 0.1.14
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/package.json +1 -1
- package/src/core/processor.ts +38 -0
- package/src/core/types.ts +8 -0
- package/src/integrations/claude-code/importer.ts +8 -2
- package/src/integrations/opencode/importer.ts +10 -3
- package/src/prompts/response/sections.ts +15 -0
- package/src/storage/interface.ts +2 -0
- package/src/storage/local.ts +5 -0
- package/tui/src/commands/quit.ts +6 -2
- package/tui/src/context/keyboard.tsx +6 -2
- package/tui/src/index.tsx +22 -0
- package/tui/src/storage/file.ts +42 -6
- package/tui/src/util/instance-lock.ts +92 -0
- package/tui/src/util/logger.ts +0 -2
- package/tui/src/util/yaml-serializers.ts +22 -0
package/package.json
CHANGED
package/src/core/processor.ts
CHANGED
|
@@ -131,6 +131,8 @@ export class Processor {
|
|
|
131
131
|
private lastClaudeCodeSync = 0;
|
|
132
132
|
private claudeCodeImportInProgress = false;
|
|
133
133
|
private pendingConflict: StateConflictData | null = null;
|
|
134
|
+
private storage: Storage | null = null;
|
|
135
|
+
private importAbortController = new AbortController();
|
|
134
136
|
|
|
135
137
|
constructor(ei: Ei_Interface) {
|
|
136
138
|
this.interface = ei;
|
|
@@ -150,6 +152,7 @@ export class Processor {
|
|
|
150
152
|
|
|
151
153
|
async start(storage: Storage): Promise<void> {
|
|
152
154
|
console.log(`[Processor ${this.instanceId}] start() called`);
|
|
155
|
+
this.storage = storage;
|
|
153
156
|
await this.stateManager.initialize(storage);
|
|
154
157
|
if (this.stopped) {
|
|
155
158
|
console.log(`[Processor ${this.instanceId}] stopped during init, not starting loop`);
|
|
@@ -258,6 +261,7 @@ export class Processor {
|
|
|
258
261
|
}
|
|
259
262
|
|
|
260
263
|
this.running = false;
|
|
264
|
+
this.importAbortController.abort();
|
|
261
265
|
this.queueProcessor.abort();
|
|
262
266
|
await this.stateManager.flush();
|
|
263
267
|
console.log(`[Processor ${this.instanceId}] stopped`);
|
|
@@ -268,6 +272,7 @@ export class Processor {
|
|
|
268
272
|
this.interface.onSaveAndExitStart?.();
|
|
269
273
|
|
|
270
274
|
this.queueProcessor.abort();
|
|
275
|
+
this.importAbortController.abort();
|
|
271
276
|
if (this.openCodeImportInProgress) {
|
|
272
277
|
console.log(`[Processor ${this.instanceId}] Aborting OpenCode import in progress`);
|
|
273
278
|
this.openCodeImportInProgress = false;
|
|
@@ -332,6 +337,7 @@ export class Processor {
|
|
|
332
337
|
}
|
|
333
338
|
|
|
334
339
|
this.pendingConflict = null;
|
|
340
|
+
this.importAbortController = new AbortController();
|
|
335
341
|
this.running = true;
|
|
336
342
|
this.runLoop();
|
|
337
343
|
this.interface.onStateImported?.();
|
|
@@ -391,6 +397,9 @@ export class Processor {
|
|
|
391
397
|
await this.checkAndSyncOpenCode(human, now);
|
|
392
398
|
}
|
|
393
399
|
|
|
400
|
+
if (this.isTUI && human.settings?.backup?.enabled) {
|
|
401
|
+
await this.checkAndRunRollingBackup(human, now);
|
|
402
|
+
}
|
|
394
403
|
if (this.isTUI && human.settings?.claudeCode?.integration && this.stateManager.queue_length() === 0) {
|
|
395
404
|
await this.checkAndSyncClaudeCode(human, now);
|
|
396
405
|
}
|
|
@@ -436,6 +445,33 @@ export class Processor {
|
|
|
436
445
|
}
|
|
437
446
|
}
|
|
438
447
|
|
|
448
|
+
private async checkAndRunRollingBackup(human: HumanEntity, now: number): Promise<void> {
|
|
449
|
+
if (!this.storage) return;
|
|
450
|
+
const cfg = human.settings!.backup!;
|
|
451
|
+
const intervalMs = cfg.interval_ms ?? 3_600_000; // default: 1 hour
|
|
452
|
+
const maxBackups = cfg.max_backups ?? 24;
|
|
453
|
+
const lastBackup = cfg.last_backup ? new Date(cfg.last_backup).getTime() : 0;
|
|
454
|
+
|
|
455
|
+
if (now - lastBackup < intervalMs) return;
|
|
456
|
+
|
|
457
|
+
// Update timestamp BEFORE async work to prevent duplicate triggers
|
|
458
|
+
this.stateManager.setHuman({
|
|
459
|
+
...this.stateManager.getHuman(),
|
|
460
|
+
settings: {
|
|
461
|
+
...this.stateManager.getHuman().settings,
|
|
462
|
+
backup: { ...cfg, last_backup: new Date(now).toISOString() },
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const state = this.stateManager.getStorageState();
|
|
467
|
+
try {
|
|
468
|
+
await this.storage.saveRollingBackup(state, maxBackups);
|
|
469
|
+
console.log(`[Processor] Rolling backup saved (max=${maxBackups})`);
|
|
470
|
+
} catch (err) {
|
|
471
|
+
console.warn(`[Processor] Rolling backup failed:`, err);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
439
475
|
private async checkAndSyncOpenCode(human: HumanEntity, now: number): Promise<void> {
|
|
440
476
|
if (this.openCodeImportInProgress) {
|
|
441
477
|
return;
|
|
@@ -471,6 +507,7 @@ export class Processor {
|
|
|
471
507
|
importOpenCodeSessions({
|
|
472
508
|
stateManager: this.stateManager,
|
|
473
509
|
interface: this.interface,
|
|
510
|
+
signal: this.importAbortController.signal,
|
|
474
511
|
})
|
|
475
512
|
)
|
|
476
513
|
.then((result) => {
|
|
@@ -525,6 +562,7 @@ export class Processor {
|
|
|
525
562
|
importClaudeCodeSessions({
|
|
526
563
|
stateManager: this.stateManager,
|
|
527
564
|
interface: this.interface,
|
|
565
|
+
signal: this.importAbortController.signal,
|
|
528
566
|
})
|
|
529
567
|
)
|
|
530
568
|
.then((result) => {
|
package/src/core/types.ts
CHANGED
|
@@ -192,6 +192,13 @@ export interface CeremonyConfig {
|
|
|
192
192
|
dedup_threshold?: number; // Cosine similarity threshold for dedup candidates. Default: 0.85
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
export interface BackupConfig {
|
|
196
|
+
enabled?: boolean; // Default: false (opt-in)
|
|
197
|
+
max_backups?: number; // Default: 24
|
|
198
|
+
interval_ms?: number; // Default: 3600000 (1 hour)
|
|
199
|
+
last_backup?: string; // ISO timestamp of last backup run
|
|
200
|
+
}
|
|
201
|
+
|
|
195
202
|
export interface HumanSettings {
|
|
196
203
|
default_model?: string;
|
|
197
204
|
oneshot_model?: string; // Model for AI-assist (wand) requests; falls back to default_model
|
|
@@ -203,6 +210,7 @@ export interface HumanSettings {
|
|
|
203
210
|
sync?: SyncCredentials;
|
|
204
211
|
opencode?: OpenCodeSettings;
|
|
205
212
|
ceremony?: CeremonyConfig;
|
|
213
|
+
backup?: BackupConfig;
|
|
206
214
|
claudeCode?: import("../integrations/claude-code/types.js").ClaudeCodeSettings;
|
|
207
215
|
}
|
|
208
216
|
|
|
@@ -29,6 +29,7 @@ export interface ClaudeCodeImporterOptions {
|
|
|
29
29
|
stateManager: StateManager;
|
|
30
30
|
interface?: Ei_Interface;
|
|
31
31
|
reader?: IClaudeCodeReader;
|
|
32
|
+
signal?: AbortSignal;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
// =============================================================================
|
|
@@ -183,7 +184,7 @@ function updateProcessedState(
|
|
|
183
184
|
export async function importClaudeCodeSessions(
|
|
184
185
|
options: ClaudeCodeImporterOptions
|
|
185
186
|
): Promise<ClaudeCodeImportResult> {
|
|
186
|
-
const { stateManager, interface: eiInterface } = options;
|
|
187
|
+
const { stateManager, interface: eiInterface, signal } = options;
|
|
187
188
|
const reader = options.reader ?? new ClaudeCodeReader();
|
|
188
189
|
|
|
189
190
|
const result: ClaudeCodeImportResult = {
|
|
@@ -204,6 +205,7 @@ export async function importClaudeCodeSessions(
|
|
|
204
205
|
else if (topicResult === "updated") result.topicsUpdated++;
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
if (signal?.aborted) return result;
|
|
207
209
|
if (result.topicsCreated > 0 || result.topicsUpdated > 0) {
|
|
208
210
|
eiInterface?.onHumanUpdated?.();
|
|
209
211
|
}
|
|
@@ -240,6 +242,8 @@ export async function importClaudeCodeSessions(
|
|
|
240
242
|
return result;
|
|
241
243
|
}
|
|
242
244
|
|
|
245
|
+
if (signal?.aborted) return result;
|
|
246
|
+
|
|
243
247
|
console.log(
|
|
244
248
|
`[ClaudeCode] Processing session: "${targetSession.title}" ` +
|
|
245
249
|
`(last message: ${targetSession.lastMessageAt})`
|
|
@@ -253,6 +257,8 @@ export async function importClaudeCodeSessions(
|
|
|
253
257
|
return result;
|
|
254
258
|
}
|
|
255
259
|
|
|
260
|
+
if (signal?.aborted) return result;
|
|
261
|
+
|
|
256
262
|
// ─── Step 4: Ensure persona, archive, clear, write messages ──────────
|
|
257
263
|
const persona = ensureClaudeCodePersona(stateManager, eiInterface);
|
|
258
264
|
result.personaCreated = !stateManager.persona_getByName(CLAUDE_CODE_PERSONA_NAME);
|
|
@@ -286,7 +292,7 @@ export async function importClaudeCodeSessions(
|
|
|
286
292
|
eiInterface?.onMessageAdded?.(persona.id);
|
|
287
293
|
|
|
288
294
|
// ─── Step 5: Queue extraction for new messages ────────────────────────
|
|
289
|
-
if (toAnalyze.length > 0) {
|
|
295
|
+
if (toAnalyze.length > 0 && !signal?.aborted) {
|
|
290
296
|
const allInState = stateManager.messages_get(persona.id);
|
|
291
297
|
const analyzeIds = new Set(toAnalyze.map((m) => m.id));
|
|
292
298
|
const analyzeStartIndex = allInState.findIndex((m) => analyzeIds.has(m.id));
|
|
@@ -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
|
-
|
|
245
|
-
|
|
249
|
+
if (!signal?.aborted) {
|
|
250
|
+
queueAllScans(context, stateManager);
|
|
251
|
+
result.extractionScansQueued += 4;
|
|
252
|
+
}
|
|
246
253
|
}
|
|
247
254
|
}
|
|
248
255
|
|
|
@@ -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
|
package/src/storage/interface.ts
CHANGED
|
@@ -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
|
}
|
package/src/storage/local.ts
CHANGED
|
@@ -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/src/commands/quit.ts
CHANGED
|
@@ -11,8 +11,12 @@ export const quitCommand: Command = {
|
|
|
11
11
|
if (forceQuit) {
|
|
12
12
|
ctx.showNotification("Force quitting...", "info");
|
|
13
13
|
await ctx.stopProcessor();
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
try {
|
|
15
|
+
ctx.renderer.setTerminalTitle("");
|
|
16
|
+
ctx.renderer.destroy();
|
|
17
|
+
} catch {
|
|
18
|
+
// Terminal may already be gone; proceed to exit anyway
|
|
19
|
+
}
|
|
16
20
|
process.exit(0);
|
|
17
21
|
}
|
|
18
22
|
|
|
@@ -61,8 +61,12 @@ export const KeyboardProvider: ParentComponent = (props) => {
|
|
|
61
61
|
showNotification(`Sync failed: ${result.error}. Use /quit force to exit anyway.`, "error");
|
|
62
62
|
return;
|
|
63
63
|
}
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
try {
|
|
65
|
+
renderer.setTerminalTitle("");
|
|
66
|
+
renderer.destroy();
|
|
67
|
+
} catch {
|
|
68
|
+
// Terminal may already be gone (e.g. parent session closed); proceed to exit anyway
|
|
69
|
+
}
|
|
66
70
|
process.exit(0);
|
|
67
71
|
};
|
|
68
72
|
|
package/tui/src/index.tsx
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
|
+
import { join } from "path";
|
|
1
2
|
import { render } from "@opentui/solid";
|
|
2
3
|
import { App } from "./app";
|
|
3
4
|
|
|
5
|
+
import { InstanceLock } from "./util/instance-lock";
|
|
6
|
+
import { FileStorage } from "./storage/file";
|
|
7
|
+
|
|
8
|
+
const storage = new FileStorage(Bun.env.EI_DATA_PATH);
|
|
9
|
+
const lock = new InstanceLock(storage.getDataPath());
|
|
10
|
+
const lockResult = await lock.acquire();
|
|
11
|
+
|
|
12
|
+
if (!lockResult.acquired) {
|
|
13
|
+
process.stderr.write(
|
|
14
|
+
`\nEi cannot start: another instance is already running.\n` +
|
|
15
|
+
` PID: ${lockResult.pid}\n` +
|
|
16
|
+
` Started: ${lockResult.started}\n` +
|
|
17
|
+
` Lock: ${join(storage.getDataPath(), "ei.lock")}\n\n` +
|
|
18
|
+
`Close the other instance first, or delete the lock file if it is stale.\n\n`
|
|
19
|
+
);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Release lock when the app exits (keyboard context calls process.exit(0) on normal quit)
|
|
24
|
+
process.on("exit", () => { void lock.release(); });
|
|
25
|
+
|
|
4
26
|
render(App, {
|
|
5
27
|
exitOnCtrlC: false,
|
|
6
28
|
targetFps: 30,
|
package/tui/src/storage/file.ts
CHANGED
|
@@ -1,27 +1,31 @@
|
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
this.dataPath = process.env.EI_DATA_PATH;
|
|
16
|
+
const raw = dataPath ?? process.env.EI_DATA_PATH;
|
|
17
|
+
if (raw) {
|
|
18
|
+
this.dataPath = raw.replace(/\/+$/, "");
|
|
19
19
|
} else {
|
|
20
20
|
const xdgData = process.env.XDG_DATA_HOME || join(process.env.HOME || "~", ".local", "share");
|
|
21
21
|
this.dataPath = join(xdgData, "ei");
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
getDataPath(): string {
|
|
26
|
+
return this.dataPath;
|
|
27
|
+
}
|
|
28
|
+
|
|
25
29
|
async isAvailable(): Promise<boolean> {
|
|
26
30
|
try {
|
|
27
31
|
await this.ensureDataDir();
|
|
@@ -101,6 +105,38 @@ export class FileStorage implements Storage {
|
|
|
101
105
|
|
|
102
106
|
return null;
|
|
103
107
|
}
|
|
108
|
+
async saveRollingBackup(state: StorageState, maxBackups: number): Promise<void> {
|
|
109
|
+
const backupsPath = join(this.dataPath, BACKUPS_DIR);
|
|
110
|
+
await mkdir(backupsPath, { recursive: true });
|
|
111
|
+
|
|
112
|
+
// Filename is local timestamp: YYYY-MM-DDTHH-MM-SS (colons replaced for FS compat)
|
|
113
|
+
const now = new Date();
|
|
114
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
115
|
+
const name = [
|
|
116
|
+
now.getFullYear(),
|
|
117
|
+
"-", pad(now.getMonth() + 1),
|
|
118
|
+
"-", pad(now.getDate()),
|
|
119
|
+
"T", pad(now.getHours()),
|
|
120
|
+
"-", pad(now.getMinutes()),
|
|
121
|
+
"-", pad(now.getSeconds()),
|
|
122
|
+
].join("") + ".json";
|
|
123
|
+
|
|
124
|
+
const destPath = join(backupsPath, name);
|
|
125
|
+
await this.atomicWrite(destPath, JSON.stringify(state, null, 2));
|
|
126
|
+
|
|
127
|
+
// Prune: keep only the newest maxBackups files
|
|
128
|
+
const entries = await readdir(backupsPath);
|
|
129
|
+
const jsonFiles = entries
|
|
130
|
+
.filter(f => f.endsWith(".json"))
|
|
131
|
+
.sort(); // ISO-like names sort chronologically
|
|
132
|
+
|
|
133
|
+
const excess = jsonFiles.length - maxBackups;
|
|
134
|
+
if (excess > 0) {
|
|
135
|
+
for (const old of jsonFiles.slice(0, excess)) {
|
|
136
|
+
await unlink(join(backupsPath, old));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
104
140
|
|
|
105
141
|
private async ensureDataDir(): Promise<void> {
|
|
106
142
|
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
|
+
}
|
package/tui/src/util/logger.ts
CHANGED
|
@@ -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 {}
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
HumanSettings,
|
|
6
6
|
CeremonyConfig,
|
|
7
7
|
OpenCodeSettings,
|
|
8
|
+
BackupConfig,
|
|
8
9
|
Fact,
|
|
9
10
|
Trait,
|
|
10
11
|
Topic,
|
|
@@ -455,6 +456,11 @@ interface EditableSettingsData {
|
|
|
455
456
|
integration?: boolean | null;
|
|
456
457
|
polling_interval_ms?: number | null;
|
|
457
458
|
};
|
|
459
|
+
backup?: {
|
|
460
|
+
enabled?: boolean | null;
|
|
461
|
+
max_backups?: number | null;
|
|
462
|
+
interval_ms?: number | null;
|
|
463
|
+
};
|
|
458
464
|
}
|
|
459
465
|
|
|
460
466
|
export function settingsToYAML(settings: HumanSettings | undefined): string {
|
|
@@ -477,6 +483,11 @@ export function settingsToYAML(settings: HumanSettings | undefined): string {
|
|
|
477
483
|
integration: settings?.claudeCode?.integration ?? false,
|
|
478
484
|
polling_interval_ms: settings?.claudeCode?.polling_interval_ms ?? 1800000,
|
|
479
485
|
},
|
|
486
|
+
backup: {
|
|
487
|
+
enabled: settings?.backup?.enabled ?? false,
|
|
488
|
+
max_backups: settings?.backup?.max_backups ?? 24,
|
|
489
|
+
interval_ms: settings?.backup?.interval_ms ?? 3600000,
|
|
490
|
+
},
|
|
480
491
|
};
|
|
481
492
|
|
|
482
493
|
return YAML.stringify(data, {
|
|
@@ -521,6 +532,16 @@ export function settingsFromYAML(yamlContent: string, original: HumanSettings |
|
|
|
521
532
|
processed_sessions: original?.claudeCode?.processed_sessions,
|
|
522
533
|
};
|
|
523
534
|
}
|
|
535
|
+
|
|
536
|
+
let backup: BackupConfig | undefined;
|
|
537
|
+
if (data.backup) {
|
|
538
|
+
backup = {
|
|
539
|
+
enabled: nullToUndefined(data.backup.enabled),
|
|
540
|
+
max_backups: nullToUndefined(data.backup.max_backups),
|
|
541
|
+
interval_ms: nullToUndefined(data.backup.interval_ms),
|
|
542
|
+
last_backup: original?.backup?.last_backup,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
524
545
|
|
|
525
546
|
return {
|
|
526
547
|
...original,
|
|
@@ -530,6 +551,7 @@ export function settingsFromYAML(yamlContent: string, original: HumanSettings |
|
|
|
530
551
|
ceremony,
|
|
531
552
|
opencode,
|
|
532
553
|
claudeCode,
|
|
554
|
+
backup,
|
|
533
555
|
};
|
|
534
556
|
}
|
|
535
557
|
|