letmecook 0.0.15 → 0.0.17

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.
@@ -1,17 +1,19 @@
1
1
  import type { CliRenderer } from "@opentui/core";
2
2
  import type { Session } from "../types";
3
3
  import { updateSessionSettings } from "../sessions";
4
- import { refreshLatestRepos } from "../git";
4
+ import { refreshReferenceRepos } from "../git";
5
5
  import { updateSkills } from "../skills";
6
6
  import { handleSmartExit } from "../ui/exit";
7
7
  import { showSessionSettings } from "../ui/session-settings";
8
8
  import { showDeleteConfirm } from "../ui/confirm-delete";
9
+ import { showSessionStartWarning } from "../ui/background-warning";
9
10
  import { showProgress, updateProgress, hideProgress } from "../ui/progress";
10
11
  import { showReclonePrompt } from "../ui/reclone-prompt";
11
12
  import { deleteSession } from "../sessions";
12
- import { recloneRepo } from "../git";
13
13
  import { writeAgentsMd } from "../agents-md";
14
14
  import { createRenderer, destroyRenderer } from "../ui/renderer";
15
+ import { getProcessesForSession } from "../process-registry";
16
+ import { repairReferenceLink } from "../reference-repo";
15
17
 
16
18
  export interface ResumeSessionParams {
17
19
  session: Session;
@@ -30,13 +32,27 @@ export async function resumeSession(
30
32
  let currentSession = session;
31
33
  let shouldRefresh = initialRefresh;
32
34
 
35
+ // Check for background processes running for this session
36
+ if (mode === "tui") {
37
+ const runningProcesses = await getProcessesForSession(session.name);
38
+ if (runningProcesses.length > 0) {
39
+ renderer = await createRenderer();
40
+ const choice = await showSessionStartWarning(renderer, runningProcesses);
41
+ destroyRenderer();
42
+ if (choice === "cancel") {
43
+ return "home";
44
+ }
45
+ // "continue" proceeds with warning acknowledged
46
+ }
47
+ }
48
+
33
49
  while (true) {
34
50
  if (mode === "tui" && shouldRefresh) {
35
51
  renderer = await createRenderer();
36
- await refreshLatestBeforeResume(renderer, currentSession);
52
+ await refreshReferenceBeforeResume(renderer, currentSession);
37
53
  destroyRenderer();
38
54
  } else if (shouldRefresh) {
39
- await refreshLatestBeforeResumeSimple(currentSession);
55
+ await refreshReferenceBeforeResumeSimple(currentSession);
40
56
  }
41
57
 
42
58
  await runOpencodeMode(mode, currentSession.path);
@@ -101,20 +117,23 @@ export async function resumeSession(
101
117
  }
102
118
  }
103
119
 
104
- async function refreshLatestBeforeResume(renderer: CliRenderer, session: Session): Promise<void> {
105
- const latestRepos = session.repos.filter((repo) => repo.latest);
106
- if (latestRepos.length === 0) return;
120
+ async function refreshReferenceBeforeResume(
121
+ renderer: CliRenderer,
122
+ session: Session,
123
+ ): Promise<void> {
124
+ const referenceRepos = session.repos.filter((repo) => repo.reference);
125
+ if (referenceRepos.length === 0) return;
107
126
 
108
- const refreshProgressState = showProgress(renderer, latestRepos, {
127
+ const refreshProgressState = showProgress(renderer, referenceRepos, {
109
128
  title: "Refreshing repositories",
110
- label: "Refreshing latest repositories:",
129
+ label: "Refreshing reference repositories:",
111
130
  initialPhase: "refreshing",
112
131
  });
113
132
  refreshProgressState.sessionName = session.name;
114
133
  updateProgress(renderer, refreshProgressState);
115
134
 
116
- const refreshResults = await refreshLatestRepos(
117
- latestRepos,
135
+ const refreshResults = await refreshReferenceRepos(
136
+ referenceRepos,
118
137
  session.path,
119
138
  (repoIndex, status, outputLines) => {
120
139
  const repoState = refreshProgressState.repos[repoIndex];
@@ -134,59 +153,60 @@ async function refreshLatestBeforeResume(renderer: CliRenderer, session: Session
134
153
  await new Promise((resolve) => setTimeout(resolve, 700));
135
154
  hideProgress(renderer);
136
155
 
137
- const recloneTargets = refreshResults.filter(
138
- (result) => result.status === "error" && result.repo.readOnly,
156
+ // For reference repos that failed, offer to repair the link
157
+ const repairTargets = refreshResults.filter(
158
+ (result) => result.status === "error" && result.repo.reference,
139
159
  );
140
160
 
141
- for (const result of recloneTargets) {
161
+ for (const result of repairTargets) {
142
162
  const choice = await showReclonePrompt(renderer, result.repo);
143
163
 
144
164
  if (choice === "reclone") {
145
- const recloneProgressState = showProgress(renderer, [result.repo], {
146
- title: "Recloning repository",
147
- label: "Recloning:",
165
+ const repairProgressState = showProgress(renderer, [result.repo], {
166
+ title: "Repairing reference",
167
+ label: "Repairing:",
148
168
  initialPhase: "cloning",
149
169
  });
150
- recloneProgressState.sessionName = session.name;
151
- updateProgress(renderer, recloneProgressState);
170
+ repairProgressState.sessionName = session.name;
171
+ updateProgress(renderer, repairProgressState);
152
172
 
153
173
  try {
154
- await recloneRepo(result.repo, session.path, (status, outputLines) => {
155
- const repoState = recloneProgressState.repos[0];
174
+ await repairReferenceLink(result.repo, session.path, (status, outputLines) => {
175
+ const repoState = repairProgressState.repos[0];
156
176
  if (repoState) {
157
177
  repoState.status = status;
158
178
  if (outputLines) {
159
- recloneProgressState.currentOutput = outputLines;
179
+ repairProgressState.currentOutput = outputLines;
160
180
  }
161
- updateProgress(renderer, recloneProgressState);
181
+ updateProgress(renderer, repairProgressState);
162
182
  }
163
183
  });
164
184
  } catch (error) {
165
- const repoState = recloneProgressState.repos[0];
185
+ const repoState = repairProgressState.repos[0];
166
186
  if (repoState) {
167
187
  repoState.status = "error";
168
188
  }
169
- recloneProgressState.currentOutput = [
189
+ repairProgressState.currentOutput = [
170
190
  error instanceof Error ? error.message : String(error),
171
191
  ];
172
- updateProgress(renderer, recloneProgressState);
192
+ updateProgress(renderer, repairProgressState);
173
193
  }
174
194
 
175
- recloneProgressState.phase = "done";
176
- updateProgress(renderer, recloneProgressState);
195
+ repairProgressState.phase = "done";
196
+ updateProgress(renderer, repairProgressState);
177
197
  await new Promise((resolve) => setTimeout(resolve, 700));
178
198
  hideProgress(renderer);
179
199
  }
180
200
  }
181
201
  }
182
202
 
183
- async function refreshLatestBeforeResumeSimple(session: Session): Promise<void> {
184
- const latestRepos = session.repos.filter((repo) => repo.latest);
185
- if (latestRepos.length === 0) return;
203
+ async function refreshReferenceBeforeResumeSimple(session: Session): Promise<void> {
204
+ const referenceRepos = session.repos.filter((repo) => repo.reference);
205
+ if (referenceRepos.length === 0) return;
186
206
 
187
- console.log("\nRefreshing latest repositories...");
207
+ console.log("\nRefreshing reference repositories...");
188
208
 
189
- const results = await refreshLatestRepos(latestRepos, session.path);
209
+ const results = await refreshReferenceRepos(referenceRepos, session.path);
190
210
 
191
211
  if (results.length === 0) return;
192
212
 
package/src/git.ts CHANGED
@@ -193,63 +193,101 @@ export async function sessionHasUncommittedChanges(
193
193
  return { hasChanges: reposWithChanges.length > 0, reposWithChanges };
194
194
  }
195
195
 
196
- export async function refreshLatestRepos(
196
+ export async function refreshReferenceRepos(
197
197
  repos: RepoSpec[],
198
198
  sessionPath: string,
199
199
  onProgress?: (repoIndex: number, status: RefreshProgressStatus, outputLines?: string[]) => void,
200
200
  ): Promise<RefreshResult[]> {
201
- const latestRepos = repos.filter((repo) => repo.latest);
202
- if (latestRepos.length === 0) return [];
201
+ // Import reference-repo functions dynamically to avoid circular deps
202
+ const { refreshReferenceRepo, verifyReferenceLink, repairReferenceLink, isSymlink } =
203
+ await import("./reference-repo");
204
+
205
+ const referenceRepos = repos.filter((repo) => repo.reference);
206
+ if (referenceRepos.length === 0) return [];
203
207
 
204
208
  const results: RefreshResult[] = [];
205
209
 
206
- for (const [repoIndex, repo] of latestRepos.entries()) {
210
+ for (const [repoIndex, repo] of referenceRepos.entries()) {
207
211
  const repoPath = join(sessionPath, repo.dir);
208
- const dirty = await hasUncommittedChanges(repoPath);
209
212
 
210
- if (dirty) {
211
- results.push({
212
- repo,
213
- status: "skipped",
214
- reason: "uncommitted changes",
213
+ // Check if this is a symlink (reference repo)
214
+ const isRef = await isSymlink(repoPath);
215
+
216
+ if (isRef) {
217
+ // Verify symlink is valid, repair if needed
218
+ const isValid = await verifyReferenceLink(repo, sessionPath);
219
+ if (!isValid) {
220
+ onProgress?.(repoIndex, "refreshing", [`Repairing link to ${repo.owner}/${repo.name}...`]);
221
+ try {
222
+ await repairReferenceLink(repo, sessionPath, (status, lines) => {
223
+ if (status === "cloning") onProgress?.(repoIndex, "refreshing", lines);
224
+ });
225
+ } catch (error) {
226
+ results.push({
227
+ repo,
228
+ status: "error",
229
+ reason: error instanceof Error ? error.message : String(error),
230
+ });
231
+ onProgress?.(repoIndex, "error", [
232
+ error instanceof Error ? error.message : String(error),
233
+ ]);
234
+ continue;
235
+ }
236
+ }
237
+
238
+ // Refresh the cached reference repo
239
+ const result = await refreshReferenceRepo(repo, (status, lines) => {
240
+ onProgress?.(repoIndex, status, lines);
215
241
  });
216
- onProgress?.(repoIndex, "skipped", ["Skipped: uncommitted changes"]);
217
- continue;
218
- }
242
+ results.push(result);
243
+ } else {
244
+ // Non-symlink reference repo (legacy or converted) - use standard git pull
245
+ const dirty = await hasUncommittedChanges(repoPath);
246
+
247
+ if (dirty) {
248
+ results.push({
249
+ repo,
250
+ status: "skipped",
251
+ reason: "uncommitted changes",
252
+ });
253
+ onProgress?.(repoIndex, "skipped", ["Skipped: uncommitted changes"]);
254
+ continue;
255
+ }
219
256
 
220
- onProgress?.(repoIndex, "refreshing", [`Pulling ${repo.owner}/${repo.name}...`]);
257
+ onProgress?.(repoIndex, "refreshing", [`Pulling ${repo.owner}/${repo.name}...`]);
221
258
 
222
- const proc = Bun.spawn(["git", "-C", repoPath, "pull", "--ff-only", "--depth", "1"], {
223
- stdout: "pipe",
224
- stderr: "pipe",
225
- });
259
+ const proc = Bun.spawn(["git", "-C", repoPath, "pull", "--ff-only", "--depth", "1"], {
260
+ stdout: "pipe",
261
+ stderr: "pipe",
262
+ });
226
263
 
227
- const { success, output, fullOutput } = await readProcessOutputWithBuffer(proc, {
228
- maxBufferLines: 5,
229
- onBufferUpdate: (buffer) => onProgress?.(repoIndex, "refreshing", buffer),
230
- });
231
- const exitCode = success ? 0 : 1;
264
+ const { success, output, fullOutput } = await readProcessOutputWithBuffer(proc, {
265
+ maxBufferLines: 5,
266
+ onBufferUpdate: (buffer) => onProgress?.(repoIndex, "refreshing", buffer),
267
+ });
268
+ const exitCode = success ? 0 : 1;
269
+
270
+ if (exitCode !== 0) {
271
+ const reason = fullOutput.trim() || `git pull exited with code ${exitCode}`;
272
+ results.push({
273
+ repo,
274
+ status: "error",
275
+ reason,
276
+ });
277
+ onProgress?.(repoIndex, "error", output.length > 0 ? [...output] : [reason]);
278
+ continue;
279
+ }
280
+
281
+ const normalized = fullOutput.toLowerCase();
282
+ const upToDate =
283
+ normalized.includes("already up to date") || normalized.includes("already up-to-date");
232
284
 
233
- if (exitCode !== 0) {
234
- const reason = fullOutput.trim() || `git pull exited with code ${exitCode}`;
235
285
  results.push({
236
286
  repo,
237
- status: "error",
238
- reason,
287
+ status: upToDate ? "up-to-date" : "updated",
239
288
  });
240
- onProgress?.(repoIndex, "error", output.length > 0 ? [...output] : [reason]);
241
- continue;
289
+ onProgress?.(repoIndex, upToDate ? "up-to-date" : "updated", [...output]);
242
290
  }
243
-
244
- const normalized = fullOutput.toLowerCase();
245
- const upToDate =
246
- normalized.includes("already up to date") || normalized.includes("already up-to-date");
247
-
248
- results.push({
249
- repo,
250
- status: upToDate ? "up-to-date" : "updated",
251
- });
252
- onProgress?.(repoIndex, upToDate ? "up-to-date" : "updated", [...output]);
253
291
  }
254
292
 
255
293
  return results;
@@ -0,0 +1,179 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { mkdir } from "node:fs/promises";
5
+
6
+ const DATA_DIR = join(homedir(), ".letmecook");
7
+ const DB_PATH = join(DATA_DIR, "history.sqlite");
8
+
9
+ let db: Database | null = null;
10
+
11
+ export interface BackgroundProcess {
12
+ pid: number;
13
+ command: string;
14
+ description: string;
15
+ sessionName: string;
16
+ startTime: string;
17
+ }
18
+
19
+ interface DbRow {
20
+ pid: number;
21
+ command: string;
22
+ description: string;
23
+ session_name: string;
24
+ start_time: string;
25
+ }
26
+
27
+ async function getDb(): Promise<Database> {
28
+ if (db) return db;
29
+
30
+ await mkdir(DATA_DIR, { recursive: true });
31
+ db = new Database(DB_PATH, { create: true });
32
+ db.exec(`
33
+ CREATE TABLE IF NOT EXISTS background_processes (
34
+ pid INTEGER PRIMARY KEY,
35
+ command TEXT NOT NULL,
36
+ description TEXT NOT NULL,
37
+ session_name TEXT NOT NULL,
38
+ start_time TEXT NOT NULL
39
+ );
40
+ `);
41
+
42
+ return db;
43
+ }
44
+
45
+ function rowToProcess(row: DbRow): BackgroundProcess {
46
+ return {
47
+ pid: row.pid,
48
+ command: row.command,
49
+ description: row.description,
50
+ sessionName: row.session_name,
51
+ startTime: row.start_time,
52
+ };
53
+ }
54
+
55
+ function isProcessAlive(pid: number): boolean {
56
+ try {
57
+ process.kill(pid, 0);
58
+ return true;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ export async function registerBackgroundProcess(
65
+ pid: number,
66
+ command: string,
67
+ description: string,
68
+ sessionName: string,
69
+ ): Promise<void> {
70
+ const database = await getDb();
71
+ const stmt = database.prepare(`
72
+ INSERT OR REPLACE INTO background_processes
73
+ (pid, command, description, session_name, start_time)
74
+ VALUES (?, ?, ?, ?, ?)
75
+ `);
76
+ stmt.run(pid, command, description, sessionName, new Date().toISOString());
77
+ }
78
+
79
+ export async function getRunningProcesses(): Promise<BackgroundProcess[]> {
80
+ const database = await getDb();
81
+ const stmt = database.prepare(`SELECT * FROM background_processes`);
82
+ const rows = stmt.all() as DbRow[];
83
+
84
+ const aliveProcesses: BackgroundProcess[] = [];
85
+ const deadPids: number[] = [];
86
+
87
+ for (const row of rows) {
88
+ if (isProcessAlive(row.pid)) {
89
+ aliveProcesses.push(rowToProcess(row));
90
+ } else {
91
+ deadPids.push(row.pid);
92
+ }
93
+ }
94
+
95
+ // Clean up dead processes from the database
96
+ if (deadPids.length > 0) {
97
+ const deleteStmt = database.prepare(`DELETE FROM background_processes WHERE pid = ?`);
98
+ const deleteMany = database.transaction((pids: number[]) => {
99
+ for (const pid of pids) {
100
+ deleteStmt.run(pid);
101
+ }
102
+ });
103
+ deleteMany(deadPids);
104
+ }
105
+
106
+ return aliveProcesses;
107
+ }
108
+
109
+ export async function getProcessesForSession(sessionName: string): Promise<BackgroundProcess[]> {
110
+ const database = await getDb();
111
+ const stmt = database.prepare(`SELECT * FROM background_processes WHERE session_name = ?`);
112
+ const rows = stmt.all(sessionName) as DbRow[];
113
+
114
+ const aliveProcesses: BackgroundProcess[] = [];
115
+ const deadPids: number[] = [];
116
+
117
+ for (const row of rows) {
118
+ if (isProcessAlive(row.pid)) {
119
+ aliveProcesses.push(rowToProcess(row));
120
+ } else {
121
+ deadPids.push(row.pid);
122
+ }
123
+ }
124
+
125
+ // Clean up dead processes from the database
126
+ if (deadPids.length > 0) {
127
+ const deleteStmt = database.prepare(`DELETE FROM background_processes WHERE pid = ?`);
128
+ const deleteMany = database.transaction((pids: number[]) => {
129
+ for (const pid of pids) {
130
+ deleteStmt.run(pid);
131
+ }
132
+ });
133
+ deleteMany(deadPids);
134
+ }
135
+
136
+ return aliveProcesses;
137
+ }
138
+
139
+ export async function killProcess(pid: number): Promise<boolean> {
140
+ try {
141
+ // First try SIGTERM for graceful shutdown
142
+ process.kill(pid, "SIGTERM");
143
+
144
+ // Wait up to 3 seconds for process to exit
145
+ const startTime = Date.now();
146
+ while (Date.now() - startTime < 3000) {
147
+ await new Promise((resolve) => setTimeout(resolve, 100));
148
+ if (!isProcessAlive(pid)) {
149
+ await removeProcessFromRegistry(pid);
150
+ return true;
151
+ }
152
+ }
153
+
154
+ // Process didn't exit, send SIGKILL
155
+ process.kill(pid, "SIGKILL");
156
+ await new Promise((resolve) => setTimeout(resolve, 100));
157
+
158
+ await removeProcessFromRegistry(pid);
159
+ return true;
160
+ } catch {
161
+ // Process may have already exited
162
+ await removeProcessFromRegistry(pid);
163
+ return false;
164
+ }
165
+ }
166
+
167
+ export async function killAllProcesses(): Promise<void> {
168
+ const runningProcesses = await getRunningProcesses();
169
+
170
+ for (const proc of runningProcesses) {
171
+ await killProcess(proc.pid);
172
+ }
173
+ }
174
+
175
+ async function removeProcessFromRegistry(pid: number): Promise<void> {
176
+ const database = await getDb();
177
+ const stmt = database.prepare(`DELETE FROM background_processes WHERE pid = ?`);
178
+ stmt.run(pid);
179
+ }