nightytidy 0.3.9 → 0.3.10

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nightytidy",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "description": "Automated overnight codebase improvement through Claude Code",
5
5
  "license": "MIT",
6
6
  "author": "Dorian Spitz",
@@ -723,6 +723,7 @@ export async function startAgent() {
723
723
  run: { id: run.id, progress: `${stepIndex + 1}/${totalSteps}`, costSoFar: stepData.cost, elapsedMs: stepData.duration },
724
724
  }, project.webhooks);
725
725
  stepIndex++;
726
+ runQueue.updateProgress({ ...runProgress });
726
727
  } else {
727
728
  const errorType = stepParsed.errorType;
728
729
  if (errorType === 'rate_limit') {
@@ -771,6 +772,7 @@ export async function startAgent() {
771
772
  run: { id: run.id },
772
773
  }, project.webhooks);
773
774
  stepIndex++;
775
+ runQueue.updateProgress({ ...runProgress });
774
776
  }
775
777
  }
776
778
 
@@ -959,6 +961,7 @@ export async function startAgent() {
959
961
  project: project.name, projectId: project.id, step: stepData,
960
962
  run: { id: interrupted.id, costSoFar: stepData.cost, elapsedMs: stepData.duration },
961
963
  }, project.webhooks);
964
+ runQueue.updateProgress({ ...runProgress });
962
965
  } else if (stepParsed.errorType === 'rate_limit') {
963
966
  const waitMs = stepParsed.retryAfterMs || 120000;
964
967
  info(` ⏸ Rate limited — waiting ${Math.round(waitMs / 1000)}s`);
@@ -975,6 +978,7 @@ export async function startAgent() {
975
978
  const si = runProgress.stepList.findIndex(s => s.number === stepNum);
976
979
  if (si >= 0) Object.assign(runProgress.stepList[si], { status: 'completed' });
977
980
  wsServer.broadcast({ type: 'step-completed', runId: interrupted.id, step: { number: stepNum, status: 'completed', duration: 0, cost: 0 }, cost: 0 });
981
+ runQueue.updateProgress({ ...runProgress });
978
982
  } else {
979
983
  info(` ✗ Step ${stepNum} failed: ${stepParsed.error || 'unknown'}`);
980
984
  runProgress.failedCount++;
@@ -985,6 +989,7 @@ export async function startAgent() {
985
989
  error: stepParsed.error || stepResult.stderr,
986
990
  });
987
991
  wsServer.broadcast({ type: 'step-failed', runId: interrupted.id, step: { number: stepNum }, error: stepParsed.error || stepResult.stderr });
992
+ runQueue.updateProgress({ ...runProgress });
988
993
  }
989
994
  }
990
995
 
@@ -1228,16 +1233,53 @@ export async function startAgent() {
1228
1233
  }
1229
1234
 
1230
1235
  // Also handle case where queue has a "running" entry from a crash
1231
- // (agent died without graceful shutdown, so markInterrupted was never called)
1236
+ // (agent died without graceful shutdown, so markInterrupted was never called).
1237
+ // Check for progress before discarding — force-kills may have left
1238
+ // lastProgress from per-step updateProgress() calls, or the CLI state
1239
+ // file may still exist in the target project.
1232
1240
  const current = runQueue.getCurrent();
1233
1241
  if (current && current.status === 'running' && !activeBridge) {
1234
- // Orphaned run with no progress — auto-discard instead of blocking the queue
1235
- info(`Found orphaned running run: ${current.id} auto-discarding (0 steps completed)`);
1236
- runQueue.completeCurrent({ success: false });
1237
- dispatchWithQueue('run_failed', {
1238
- projectId: current.projectId,
1239
- run: { id: current.id },
1240
- }, []);
1242
+ const savedProgress = current.lastProgress || {};
1243
+ let completedFromQueue = savedProgress.completedCount || 0;
1244
+
1245
+ // Also check CLI state file as a fallback (belt + suspenders)
1246
+ let completedFromCli = 0;
1247
+ try {
1248
+ const proj = projectManager.getProject(current.projectId);
1249
+ if (proj) {
1250
+ const stateFile = path.join(proj.path, 'nightytidy-run-state.json');
1251
+ const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
1252
+ completedFromCli = (state.completedSteps || []).length;
1253
+ }
1254
+ } catch { /* no state file — fine */ }
1255
+
1256
+ const totalCompleted = Math.max(completedFromQueue, completedFromCli);
1257
+
1258
+ if (totalCompleted === 0) {
1259
+ // No work was done — safe to auto-discard
1260
+ info(`Found orphaned running run: ${current.id} — auto-discarding (0 steps completed)`);
1261
+ runQueue.completeCurrent({ success: false });
1262
+ dispatchWithQueue('run_failed', {
1263
+ projectId: current.projectId,
1264
+ run: { id: current.id },
1265
+ }, []);
1266
+ } else {
1267
+ // Has progress — transition to interrupted so user can resume/finish/discard
1268
+ info(`Found orphaned running run: ${current.id} — transitioning to interrupted (${totalCompleted} steps completed)`);
1269
+ const progress = Object.keys(savedProgress).length > 0
1270
+ ? savedProgress
1271
+ : { completedCount: completedFromCli, failedCount: 0, totalCost: 0 };
1272
+ runQueue.markInterrupted(progress);
1273
+ dispatchWithQueue('run_interrupted', {
1274
+ projectId: current.projectId,
1275
+ run: {
1276
+ id: current.id,
1277
+ totalSteps: current.steps?.length || 0,
1278
+ completedSteps: totalCompleted,
1279
+ elapsedMs: Date.now() - (current.startedAt || Date.now()),
1280
+ },
1281
+ }, []);
1282
+ }
1241
1283
  }
1242
1284
 
1243
1285
  // Process any queued runs left from a previous session
@@ -56,6 +56,18 @@ export class RunQueue {
56
56
  }
57
57
  }
58
58
 
59
+ /**
60
+ * Persist run progress to disk so it survives force-kills and crashes.
61
+ * Called after each step completes/fails. The next startup can read
62
+ * current.lastProgress to recover the run.
63
+ */
64
+ updateProgress(progress) {
65
+ if (this.current) {
66
+ this.current.lastProgress = progress;
67
+ this._save();
68
+ }
69
+ }
70
+
59
71
  /**
60
72
  * Mark the current run as interrupted (agent shutting down mid-run).
61
73
  * Preserves progress data so the run can be resumed on restart.
@@ -88,51 +88,111 @@ export function unregisterService() {
88
88
  // --- Platform implementations ---
89
89
 
90
90
  /**
91
- * Windows: Use the Startup folder (no admin needed).
92
- * Writes a .vbs wrapper script that launches the agent hidden (no console window).
93
- * Falls back to schtasks if Startup folder isn't writable.
91
+ * Windows: Write a .cmd launcher to ~/.nightytidy/ and register it with
92
+ * Task Scheduler (ONLOGON trigger). Task Scheduler is reliable on Windows 11
93
+ * where VBS scripts in the Startup folder are silently blocked by SmartScreen.
94
+ *
95
+ * The .cmd file uses `start /min` to avoid a visible console window.
96
+ * Falls back to the Startup folder VBS approach if schtasks fails.
94
97
  */
95
98
  const STARTUP_DIR = process.platform === 'win32'
96
99
  ? path.join(os.homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
97
100
  : '';
98
101
  const STARTUP_VBS = path.join(STARTUP_DIR, 'NightyTidy Agent.vbs');
102
+ const AGENT_CMD = process.platform === 'win32'
103
+ ? path.join(os.homedir(), '.nightytidy', 'start-agent.cmd')
104
+ : '';
99
105
 
100
106
  function _registerWindows(cmd) {
101
- // Primary: Startup folder (no admin required)
107
+ // Write a .cmd launcher script to ~/.nightytidy/start-agent.cmd
108
+ // Avoids quoting issues when passing to Task Scheduler or Startup folder.
109
+ const cmdContent = `@echo off\r\nstart /min "" ${cmd}\r\n`;
110
+ const cmdDir = path.dirname(AGENT_CMD);
111
+ fs.mkdirSync(cmdDir, { recursive: true });
112
+ fs.writeFileSync(AGENT_CMD, cmdContent, 'utf-8');
113
+ debug(`Wrote launcher script: ${AGENT_CMD}`);
114
+
115
+ // Primary: Task Scheduler via PowerShell (no admin required for user-level
116
+ // logon triggers). schtasks.exe /sc onlogon needs admin, but PowerShell's
117
+ // Register-ScheduledTask with a LogonTrigger does not.
118
+ try {
119
+ const escapedPath = AGENT_CMD.replace(/'/g, "''");
120
+ const ps = [
121
+ `$action = New-ScheduledTaskAction -Execute '${escapedPath}'`,
122
+ `$trigger = New-ScheduledTaskTrigger -AtLogOn`,
123
+ `$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable`,
124
+ `Register-ScheduledTask -TaskName '${SERVICE_NAME}' -Action $action -Trigger $trigger -Settings $settings -Force`,
125
+ ].join('; ');
126
+ execSync(`powershell.exe -NoProfile -Command "${ps}"`, { stdio: 'pipe', timeout: 15000 });
127
+ debug('Registered via Task Scheduler (PowerShell)');
128
+ _removeStartupVbs();
129
+ return;
130
+ } catch (err) {
131
+ debug(`Task Scheduler failed: ${err.message}, trying Startup folder`);
132
+ }
133
+
134
+ // Fallback: Startup folder — place .cmd directly (less restricted than .vbs
135
+ // by Windows SmartScreen). The .cmd file runs the agent hidden via start /min.
102
136
  try {
103
- // VBS wrapper runs the agent without showing a console window.
104
- // In VBS, quotes inside strings are doubled: "" not \"
105
- const vbsCmd = cmd.replace(/"/g, '""');
106
- const vbs = `' NightyTidy Agent - auto-start on login\r\nSet ws = CreateObject("WScript.Shell")\r\nws.Run "${vbsCmd}", 0, False\r\n`;
107
137
  fs.mkdirSync(STARTUP_DIR, { recursive: true });
108
- fs.writeFileSync(STARTUP_VBS, vbs, 'utf-8');
109
- debug('Registered via Windows Startup folder');
138
+ const startupCmd = path.join(STARTUP_DIR, 'NightyTidy Agent.cmd');
139
+ fs.copyFileSync(AGENT_CMD, startupCmd);
140
+ debug('Registered via Startup folder (.cmd)');
141
+ _removeStartupVbs();
110
142
  return;
111
143
  } catch (err) {
112
- debug(`Startup folder failed: ${err.message}, trying schtasks`);
144
+ debug(`Startup folder .cmd failed: ${err.message}, trying VBS`);
113
145
  }
114
146
 
115
- // Fallback: Task Scheduler (needs admin on some systems)
116
- execSync(
117
- `schtasks /create /tn "${SERVICE_NAME}" /tr "${cmd}" /sc onlogon /rl LIMITED /f`,
118
- { stdio: 'pipe' },
119
- );
147
+ // Last resort: Startup folder VBS wrapper
148
+ const vbsCmd = cmd.replace(/"/g, '""');
149
+ const vbs = `' NightyTidy Agent - auto-start on login\r\nSet ws = CreateObject("WScript.Shell")\r\nws.Run "${vbsCmd}", 0, False\r\n`;
150
+ fs.mkdirSync(STARTUP_DIR, { recursive: true });
151
+ fs.writeFileSync(STARTUP_VBS, vbs, 'utf-8');
152
+ debug('Registered via Windows Startup folder (VBS)');
120
153
  }
121
154
 
122
- function _unregisterWindows() {
123
- // Remove Startup folder entry
155
+ function _removeStartupVbs() {
124
156
  try {
125
157
  if (fs.existsSync(STARTUP_VBS)) {
126
158
  fs.unlinkSync(STARTUP_VBS);
127
- debug('Removed Startup folder entry');
159
+ debug('Removed stale Startup folder VBS');
160
+ }
161
+ } catch { /* ignore */ }
162
+ }
163
+
164
+ function _unregisterWindows() {
165
+ // Remove Startup folder entries (VBS and .cmd)
166
+ _removeStartupVbs();
167
+ try {
168
+ const startupCmd = path.join(STARTUP_DIR, 'NightyTidy Agent.cmd');
169
+ if (fs.existsSync(startupCmd)) {
170
+ fs.unlinkSync(startupCmd);
171
+ debug('Removed Startup folder .cmd entry');
128
172
  }
129
173
  } catch { /* ignore */ }
130
174
 
131
- // Also try removing schtasks entry (may not exist)
175
+ // Remove .cmd launcher script from ~/.nightytidy/
132
176
  try {
133
- execSync(`schtasks /delete /tn "${SERVICE_NAME}" /f`, { stdio: 'pipe' });
177
+ if (fs.existsSync(AGENT_CMD)) {
178
+ fs.unlinkSync(AGENT_CMD);
179
+ debug('Removed launcher script');
180
+ }
181
+ } catch { /* ignore */ }
182
+
183
+ // Remove Task Scheduler entry (try both PowerShell and schtasks)
184
+ try {
185
+ execSync(
186
+ `powershell.exe -NoProfile -Command "Unregister-ScheduledTask -TaskName '${SERVICE_NAME}' -Confirm:\\$false"`,
187
+ { stdio: 'pipe', timeout: 15000 },
188
+ );
134
189
  debug('Removed Task Scheduler entry');
135
- } catch { /* ignore — may not have been registered via schtasks */ }
190
+ } catch {
191
+ try {
192
+ execSync(`schtasks /delete /tn "${SERVICE_NAME}" /f`, { stdio: 'pipe' });
193
+ debug('Removed Task Scheduler entry (schtasks)');
194
+ } catch { /* ignore — may not have been registered */ }
195
+ }
136
196
  }
137
197
 
138
198
  function _registerMacOS(cmd) {