skalpel 3.0.10 → 3.0.12

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/INSTALL.md CHANGED
@@ -47,7 +47,7 @@ The first-run flow assumes the user has obtained an `sk-skalpel-*` API key on We
47
47
 
48
48
  This flow corresponds to Journey 1 — First launch, fresh authentication — in `SPEC.md`. That narrative describes what the user feels at each step; this document describes what the install machinery actually does.
49
49
 
50
- If the user runs `skalpel` without first obtaining an API key, the TUI exits per spec §8.4 with a stderr pointer at `skalpel login`. The wizard above is the path for users who arrived through the post-signup install hub on Web; users with broken or missing auth state are returned to the shell.
50
+ If the user runs `skalpel` without first obtaining an API key, the TUI exits per spec §8.4 with a stderr pointer at `skalpel login`. The wizard above is the path for users who arrived through the post-signup install hub on Web; users with broken or missing auth state are returned to the shell. To sign out of an existing account on this machine, run `skalpel logout` from a shell — the CLI revokes the backend session (best-effort) and removes `auth.json` via `internal/auth.Delete`.
51
51
 
52
52
  An alternative first-run flow uses `skalpel login` from the shell directly. That command runs a device-code flow against Cognito (per the Auth handoff section of the cross-surface contract) and writes `auth.json` without the user having to paste an API key. The two flows produce equivalent on-disk state; the API-key-paste flow is the one a user lands in when arriving from the Web post-signup install hub, and the device-code flow is the one a user runs when reauthenticating an account that has already been signed in on this machine before.
53
53
 
@@ -75,7 +75,7 @@ A user who has rolled back to a previous version (by running `npm install -g ska
75
75
  npx skalpel uninstall
76
76
  ```
77
77
 
78
- is the canonical one-liner for a clean, full removal on every supported OS. It works whether or not skalpel was previously installed globally:
78
+ is the canonical one-liner for a clean, full removal on every supported OS. The Go binary owns the state cleanup; the Node shim owns `npm uninstall -g`. The watchdog catches `npm uninstall -g skalpel` alone within ~60s. It works whether or not skalpel was previously installed globally:
79
79
 
80
80
  1. Unregisters the per-OS service entry (launchd on macOS, systemd user unit on Linux, Task Scheduler entry on Windows).
81
81
  2. Removes the managed shell-rc block injected on install.
@@ -92,6 +92,24 @@ npm 7+ removed the `preuninstall`/`uninstall`/`postuninstall` lifecycle scripts;
92
92
 
93
93
  The configuration directory itself (auth.json + config.toml + cache + logs) is deliberately wiped by the default uninstall so a reinstalled skalpel starts from a known state. Users who want to reinstall and keep their auth + engine toggles should pass `--keep-data`.
94
94
 
95
+ ### Watchdog and cleanup.lock
96
+
97
+ `skalpel uninstall` is one path to a clean machine; `npm uninstall -g skalpel` followed by no further action is another. The daemon's **watchdog** subsystem closes the loop on the second path. Every ~30s, `skalpeld` polls its own executable path; when the binary disappears (because npm just nuked the global `node_modules`), it self-runs the same cleanup the Go CLI does, then exits.
98
+
99
+ To opt out (e.g. on shared hosts where the daemon is centrally managed), set `[daemon] watchdog_enabled = false` in `~/.config/skalpel/config.toml`:
100
+
101
+ ```toml
102
+ [daemon]
103
+ watchdog_enabled = false
104
+ ```
105
+
106
+ `cleanup.lock` is a small marker file `<configDir>/cleanup.lock` that `skalpel uninstall` writes before any destructive step. It contains `<pid>\n<unix_ms>\n`. The watchdog reads it every tick:
107
+
108
+ - If the lock's PID is alive AND its timestamp is fresher than 10 minutes → stand down (a user-initiated uninstall owns this window).
109
+ - Else (dead PID or stale timestamp) → ignore the lock and proceed as if it weren't there.
110
+
111
+ `cleanup.lock` is safe to delete manually if you see one outside an active uninstall — the next watchdog tick will recover.
112
+
95
113
  ## Version coupling
96
114
 
97
115
  `skalpel` and `skalpeld` carry the same version string at all times. The release workflow produces both from the same git tag; the npm package's `version` field, the binaries' embedded version metadata, and the cosign signatures all reference the same value. There is no path by which the two could diverge on a machine that received either via a supported install path.
package/README.md CHANGED
@@ -18,6 +18,16 @@ npm install -g skalpel
18
18
 
19
19
  Both `skalpel` and `skalpeld` are placed on your `PATH`. For details on what gets installed where, per-OS service registration, updates, and uninstall, see [INSTALL.md](./INSTALL.md).
20
20
 
21
+ ## Subcommands
22
+
23
+ `skalpel` accepts a small set of subcommands:
24
+
25
+ - `skalpel` — launch the Bubble Tea TUI (default).
26
+ - `skalpel login` — browser-loopback sign-in; writes `auth.json`.
27
+ - `skalpel logout` — revoke the active session (best-effort) and delete `auth.json`; supports `--yes` to skip the `[y/N]` confirm.
28
+ - `skalpel uninstall` — clean up skalpel state on this machine.
29
+ - `skalpel --version` / `skalpel --help` — utilities.
30
+
21
31
  ## Links
22
32
 
23
33
  - Homepage: https://skalpel.ai
@@ -143,6 +143,24 @@ function isInfoArg(argv) {
143
143
  function resolveBinary(name, argv) {
144
144
  const infoOnly = isInfoArg(argv || []);
145
145
  const failExit = infoOnly ? 0 : 1;
146
+ // Local-dev override: SKALPEL_BIN_DIR points at a directory holding
147
+ // freshly-built `skalpel` / `skalpeld` binaries (e.g. ./bin from a
148
+ // `make build`). When set we skip the optionalDependencies lookup
149
+ // entirely so `npm link` users can iterate without rebuilding the
150
+ // platform tarballs each cycle.
151
+ const overrideDir = process.env.SKALPEL_BIN_DIR;
152
+ if (overrideDir) {
153
+ const exeOverride = process.platform === 'win32' ? `${name}.exe` : name;
154
+ const candidate = path.join(overrideDir, exeOverride);
155
+ if (fs.existsSync(candidate)) return candidate;
156
+ emitError(
157
+ process.stderr,
158
+ 'SKALPEL_BIN_DIR override missing binary',
159
+ `Expected ${candidate} but it does not exist.`,
160
+ `→ Run \`make build\` in the repo root, or unset SKALPEL_BIN_DIR.`
161
+ );
162
+ process.exit(failExit);
163
+ }
146
164
  const key = `${process.platform}-${process.arch}`;
147
165
  const pkg = PLATFORM_PACKAGES[key];
148
166
  if (!pkg) {
@@ -230,22 +248,26 @@ function runUninstall(rest) {
230
248
  return 0;
231
249
  }
232
250
 
233
- const postinstall = path.join(__dirname, '..', 'postinstall', 'index.js');
234
- if (!fs.existsSync(postinstall)) {
235
- emitError(
236
- process.stderr,
237
- 'postinstall script missing',
238
- `Expected ${postinstall} but it does not exist.`,
239
- `→ Reinstall: \`npm install -g skalpel\``
240
- );
241
- return 1;
251
+ // Phase-11 (Go port): the install-side Node postinstall used to
252
+ // own uninstall as well; the cleanup is now a Go subcommand on the
253
+ // platform binary. The shim still owns the npm-package removal
254
+ // below (detectGlobalInstall → npm uninstall -g), but the
255
+ // state-tearing is dispatched to the binary.
256
+ const goBinPath = resolveBinary('skalpel', null);
257
+ if (!goBinPath) {
258
+ return 1; // resolveBinary already emitted a structured error.
242
259
  }
243
- const args = ['--uninstall'];
244
- if (cleanupData) args.push('--cleanup-data');
245
- if (dryRun) args.push('--dry-run');
260
+ const goArgs = ['uninstall'];
261
+ // Flag inversion: the shim's defaults (cleanupData=true,
262
+ // removePackage=true, dryRun=false) become the Go binary's *implicit*
263
+ // defaults, so we only forward an inverted flag when the user opted
264
+ // out (or, for dryRun, opted in).
265
+ if (!cleanupData) goArgs.push('--keep-data');
266
+ if (!removePackage) goArgs.push('--keep-package');
267
+ if (dryRun) goArgs.push('--dry-run');
246
268
 
247
- // X4 fix: pass a temp summary file path; postinstall writes a JSON
248
- // structure with per-phase counts so we can print a truthful
269
+ // X4 fix: pass a temp summary file path; the Go binary writes a
270
+ // JSON structure with per-phase counts so we can print a truthful
249
271
  // message instead of unconditionally claiming success.
250
272
  const summaryFile = path.join(
251
273
  os.tmpdir(),
@@ -255,7 +277,7 @@ function runUninstall(rest) {
255
277
  SKALPEL_UNINSTALL_SUMMARY_FILE: summaryFile,
256
278
  });
257
279
 
258
- const result = spawnSync(process.execPath, [postinstall, ...args], {
280
+ const result = spawnSync(goBinPath, goArgs, {
259
281
  stdio: 'inherit',
260
282
  env: childEnv,
261
283
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "3.0.10",
3
+ "version": "3.0.12",
4
4
  "description": "Skalpel — local proxy and TUI for coding agents (skalpel + skalpeld bundle).",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://skalpel.ai",
@@ -53,10 +53,10 @@
53
53
  "x64"
54
54
  ],
55
55
  "optionalDependencies": {
56
- "@skalpelai/skalpel-darwin-arm64": "3.0.10",
57
- "@skalpelai/skalpel-darwin-x64": "3.0.10",
58
- "@skalpelai/skalpel-linux-arm64": "3.0.10",
59
- "@skalpelai/skalpel-linux-x64": "3.0.10",
60
- "@skalpelai/skalpel-win32-x64": "3.0.10"
56
+ "@skalpelai/skalpel-darwin-arm64": "3.0.12",
57
+ "@skalpelai/skalpel-darwin-x64": "3.0.12",
58
+ "@skalpelai/skalpel-linux-arm64": "3.0.12",
59
+ "@skalpelai/skalpel-linux-x64": "3.0.12",
60
+ "@skalpelai/skalpel-win32-x64": "3.0.12"
61
61
  }
62
62
  }
@@ -44,8 +44,6 @@ function parseArgs(argv) {
44
44
  dryRun: false,
45
45
  skipBundle: false,
46
46
  verbose: false,
47
- uninstall: false,
48
- cleanupData: false,
49
47
  };
50
48
  for (const a of argv.slice(2)) {
51
49
  switch (a) {
@@ -58,14 +56,6 @@ function parseArgs(argv) {
58
56
  case '--skip-bundle':
59
57
  out.skipBundle = true;
60
58
  break;
61
- case '--uninstall':
62
- out.uninstall = true;
63
- break;
64
- case '--cleanup-data':
65
- // B29: opt-in flag to delete auth.json / config.toml /
66
- // skalpeld.lock / logs/ during --uninstall.
67
- out.cleanupData = true;
68
- break;
69
59
  case '--help':
70
60
  case '-h':
71
61
  out.help = true;
@@ -79,9 +69,6 @@ function parseArgs(argv) {
79
69
  if (process.env.SKALPEL_INSTALL_DRY_RUN === '1') {
80
70
  out.dryRun = true;
81
71
  }
82
- if (process.env.SKALPEL_UNINSTALL_CLEAN === '1') {
83
- out.cleanupData = true;
84
- }
85
72
  return out;
86
73
  }
87
74
 
@@ -89,7 +76,7 @@ function helpText() {
89
76
  return [
90
77
  'skalpel postinstall wizard',
91
78
  '',
92
- 'usage: node postinstall/index.js [--dry-run] [--verbose] [--uninstall] [--cleanup-data]',
79
+ 'usage: node postinstall/index.js [--dry-run] [--verbose]',
93
80
  '',
94
81
  'Run automatically by npm install. The wizard performs:',
95
82
  ' 1. detect-prior probe configDir for an existing install',
@@ -98,127 +85,14 @@ function helpText() {
98
85
  ' 4. env-inject update shell rc managed-block',
99
86
  ' 5. launch print the run-skalpel hint',
100
87
  '',
101
- '--uninstall reverses steps 3 and 4: unregisters the daemon and',
102
- ' removes the managed block from each rc file.',
103
- '--cleanup-data (with --uninstall) also deletes auth.json,',
104
- ' config.toml, skalpeld.lock, and logs/. Equivalent env var:',
105
- ' SKALPEL_UNINSTALL_CLEAN=1.',
88
+ 'Uninstall is owned by the Go binary now run `skalpel uninstall`',
89
+ 'or `npx skalpel uninstall` to reverse this wizard.',
106
90
  '',
107
91
  'Dry-run mode (SKALPEL_INSTALL_DRY_RUN=1 or --dry-run) makes every',
108
92
  'step log what it would do and write nothing.',
109
93
  ].join('\n');
110
94
  }
111
95
 
112
- // B29: best-effort delete of user data on --uninstall --cleanup-data.
113
- // X5 fix: previously silent on absent files. Now logs "absent — skip"
114
- // for every checked path so the user can see what was inspected.
115
- // X7 fix: also clean cache/, skalpel-tui.log, skalpeld.log, socket
116
- // markers, and stats.cache — the pre-X7 list was just 4 paths and
117
- // left those behind, defeating the "full wipe" promise.
118
- function cleanupUserData({ dryRun }) {
119
- const removed = [];
120
- const skipped = [];
121
- const cfg = paths.configDir();
122
- const fileTargets = [
123
- paths.authFile(), // auth.json
124
- paths.configToml(), // config.toml
125
- paths.lockFile(), // skalpeld.lock
126
- path.join(cfg, 'skalpel-tui.log'), // X7: TUI logger sink
127
- path.join(cfg, 'skalpeld.log'), // X7: daemon log
128
- path.join(cfg, 'skalpeld.sock'), // X7: daemon socket
129
- path.join(cfg, 'skalpeld.sock.path'), // X7: socket path marker
130
- path.join(cfg, 'stats.cache'), // X7: stats cache
131
- ];
132
- const dirTargets = [
133
- paths.logsDir(), // logs/
134
- path.join(cfg, 'cache'), // X7: model-fingerprint cache
135
- ];
136
-
137
- for (const t of fileTargets) {
138
- const present = fs.existsSync(t);
139
- if (dryRun) {
140
- if (present) {
141
- log.dryRun(`uninstall-cleanup: would rm ${t}`);
142
- removed.push(t);
143
- } else {
144
- log.dryRun(`uninstall-cleanup: ${t} absent — would skip`);
145
- skipped.push(t);
146
- }
147
- continue;
148
- }
149
- try {
150
- if (present) {
151
- fs.rmSync(t, { force: true });
152
- log.info(`uninstall-cleanup: removed ${t}`);
153
- removed.push(t);
154
- } else {
155
- log.info(`uninstall-cleanup: ${t} absent — skip`);
156
- skipped.push(t);
157
- }
158
- } catch (err) {
159
- log.warn(`uninstall-cleanup: rm ${t} failed: ${err.message}`);
160
- }
161
- }
162
- for (const d of dirTargets) {
163
- const present = fs.existsSync(d);
164
- if (dryRun) {
165
- if (present) {
166
- log.dryRun(`uninstall-cleanup: would rm -r ${d}`);
167
- removed.push(d);
168
- } else {
169
- log.dryRun(`uninstall-cleanup: ${d} absent — would skip`);
170
- skipped.push(d);
171
- }
172
- continue;
173
- }
174
- try {
175
- if (present) {
176
- fs.rmSync(d, { recursive: true, force: true });
177
- log.info(`uninstall-cleanup: removed ${d}`);
178
- removed.push(d);
179
- } else {
180
- log.info(`uninstall-cleanup: ${d} absent — skip`);
181
- skipped.push(d);
182
- }
183
- } catch (err) {
184
- log.warn(`uninstall-cleanup: rm -r ${d} failed: ${err.message}`);
185
- }
186
- }
187
- // Try to remove the (now-empty) configDir; ENOTEMPTY means the
188
- // user has third-party state under it we deliberately did not
189
- // touch — leave it alone.
190
- if (!dryRun) {
191
- try {
192
- fs.rmdirSync(cfg);
193
- log.info(`uninstall-cleanup: removed empty ${cfg}`);
194
- removed.push(cfg);
195
- } catch (err) {
196
- if (err.code === 'ENOTEMPTY') {
197
- log.info(`uninstall-cleanup: ${cfg} not empty (third-party files preserved) — skip rmdir`);
198
- } else if (err.code !== 'ENOENT') {
199
- log.warn(`uninstall-cleanup: rmdir ${cfg} failed: ${err.message}`);
200
- }
201
- }
202
- } else {
203
- log.dryRun(`uninstall-cleanup: would rmdir (if empty) ${cfg}`);
204
- }
205
- return { removed, skipped };
206
- }
207
-
208
- // X4 fix: write a JSON summary of what the uninstall actually did to
209
- // the path in SKALPEL_UNINSTALL_SUMMARY_FILE, so npm-bin/skalpel.js
210
- // (which inherited stdio to this child) can derive a truthful count
211
- // summary without parsing log lines.
212
- function writeSummaryFile(summary) {
213
- const out = process.env.SKALPEL_UNINSTALL_SUMMARY_FILE;
214
- if (!out) return;
215
- try {
216
- fs.writeFileSync(out, JSON.stringify(summary), { mode: 0o600 });
217
- } catch (err) {
218
- log.warn(`uninstall: failed to write summary to ${out}: ${err.message}`);
219
- }
220
- }
221
-
222
96
  function main(argv) {
223
97
  const opts = parseArgs(argv);
224
98
  if (opts.help) {
@@ -226,26 +100,13 @@ function main(argv) {
226
100
  return 0;
227
101
  }
228
102
 
229
- // npx mode: prior to v3.0.9 we returned exit 1 here, which caused
230
- // npm to roll back the cached package install leaving an empty
231
- // ~/.npm/_npx/<hash>/ dir and a silent failure that re-prompted on
232
- // every subsequent `npx skalpel` call. The original reason for the
233
- // gate (B32) was that paths.binPath() couldn't locate the platform
234
- // binary under npx's directory layout. That was fixed in v3.0.1
235
- // when binPath was rewritten to use require.resolve against the
236
- // platform sub-package (see paths.js:99-116) — which works under
237
- // npx, global, or local install paths uniformly.
238
- //
239
- // We still skip the install wizard under npx because the wizard's
240
- // service-register step would bake the npx cache path
241
- // (~/.npm/_npx/<hash>/...) into the OS service unit, and that path
242
- // is evicted by npx's cache GC. Exit 0 instead of 1 so npm install
243
- // completes and the binary becomes invokable.
244
- //
245
- // Uninstall, however, MUST run under npx: a user running
246
- // `npx skalpel uninstall` to clean up a prior `npm install -g`
247
- // would otherwise hit the same skip and have nothing happen.
248
- if (paths.isNpxInvocation() && !opts.uninstall) {
103
+ // npx mode: skip the install wizard. The wizard's service-register
104
+ // step would bake the npx cache path (~/.npm/_npx/<hash>/...) into
105
+ // the OS service unit, and that path is evicted by npx's cache GC.
106
+ // Exit 0 instead of 1 so npm install completes and the binary
107
+ // becomes invokable. (Uninstall is owned by the Go binary now and
108
+ // is unaffected.)
109
+ if (paths.isNpxInvocation()) {
249
110
  log.info(
250
111
  'npx mode detected — skipping install wizard (service-register / ' +
251
112
  'env-inject would bake transient npx cache paths into your system).'
@@ -259,77 +120,13 @@ function main(argv) {
259
120
 
260
121
  const total = 5;
261
122
  const mode = opts.dryRun ? 'dry-run' : 'live';
262
- const action = opts.uninstall ? 'uninstall' : 'install';
263
- log.info(`postinstall wizard starting (${mode} ${action}) on ${process.platform}`);
123
+ log.info(`postinstall wizard starting (${mode} install) on ${process.platform}`);
264
124
 
265
125
  // B28: classify failures. Critical = re-throw and exit 0 (we do
266
126
  // not want npm install to abort, but the operator should know).
267
127
  // Warning = collect and log at end.
268
128
  const allWarnings = [];
269
129
 
270
- if (opts.uninstall) {
271
- // X4 fix: count what was actually removed in each phase so the
272
- // shim can print a truthful summary instead of a blanket "✓ state
273
- // removed" that lies when the system was already clean.
274
- const summary = {
275
- rcBlocksRemoved: 0,
276
- serviceFileRemoved: false,
277
- serviceUnloadSucceeded: 0,
278
- serviceUnloadFailedAllowed: 0,
279
- serviceUnloadSkipped: false,
280
- userDataFilesRemoved: 0,
281
- userDataFilesSkipped: 0,
282
- cleanupDataRequested: !!opts.cleanupData,
283
- dryRun: !!opts.dryRun,
284
- errors: [],
285
- };
286
- try {
287
- const stepCount = opts.cleanupData ? 3 : 2;
288
- log.step(1, stepCount, 'env-uninject', 'remove managed-block from rc files');
289
- const ei = envInject.uninject({ dryRun: opts.dryRun }) || {};
290
- summary.rcBlocksRemoved = Array.isArray(ei.removed) ? ei.removed.length : 0;
291
-
292
- log.step(2, stepCount, 'service-unregister', `OS=${process.platform}`);
293
- const ur = serviceRegister.unregister({ dryRun: opts.dryRun }) || {};
294
- if (Array.isArray(ur.errors) && ur.errors.length) {
295
- allWarnings.push(...ur.errors);
296
- summary.errors.push(...ur.errors);
297
- }
298
- summary.serviceFileRemoved = !!ur.serviceFileRemoved;
299
- summary.serviceUnloadSucceeded = ur.succeeded || 0;
300
- summary.serviceUnloadFailedAllowed = ur.failedAllowed || 0;
301
- summary.serviceUnloadSkipped = !!ur.skipped;
302
-
303
- // B29: optional user-data cleanup.
304
- if (opts.cleanupData) {
305
- log.step(3, stepCount, 'cleanup-user-data', 'delete auth.json/config.toml/lock/logs/cache');
306
- const cd = cleanupUserData({ dryRun: opts.dryRun }) || {};
307
- summary.userDataFilesRemoved = Array.isArray(cd.removed) ? cd.removed.length : 0;
308
- summary.userDataFilesSkipped = Array.isArray(cd.skipped) ? cd.skipped.length : 0;
309
- } else {
310
- log.info(
311
- 'uninstall: user data preserved (auth.json/config.toml/lock/logs). ' +
312
- 'Pass --cleanup-data or set SKALPEL_UNINSTALL_CLEAN=1 to remove.'
313
- );
314
- }
315
- } catch (err) {
316
- log.error(`uninstall failed: ${err.message}`);
317
- if (opts.verbose) {
318
- process.stderr.write(`${err.stack}\n`);
319
- }
320
- summary.errors.push(err.message);
321
- writeSummaryFile(summary);
322
- return 0;
323
- }
324
- writeSummaryFile(summary);
325
- if (allWarnings.length) {
326
- log.warn(`postinstall wizard finished (${mode} uninstall) with ${allWarnings.length} warning(s)`);
327
- } else {
328
- log.info(`postinstall wizard finished (${mode} uninstall)`);
329
- }
330
- return 0;
331
- }
332
-
333
130
  let prior = false;
334
131
  let critical = null;
335
132
  try {
@@ -393,4 +190,4 @@ if (require.main === module) {
393
190
  process.exit(main(process.argv));
394
191
  }
395
192
 
396
- module.exports = { main, parseArgs, cleanupUserData };
193
+ module.exports = { main, parseArgs };
@@ -95,34 +95,4 @@ function run({ dryRun, port }) {
95
95
  return { touched };
96
96
  }
97
97
 
98
- function uninject({ dryRun }) {
99
- const rcs = paths.rcFiles();
100
- const removed = [];
101
- for (const rc of rcs) {
102
- const exists = fs.existsSync(rc.path);
103
- if (!exists) {
104
- log.info(`uninstall: ${rc.shell} rc absent (${rc.path}) — skip`);
105
- continue;
106
- }
107
- if (dryRun) {
108
- log.dryRun(`uninstall: would remove managed block from ${rc.path} (${rc.shell})`);
109
- removed.push(rc.path);
110
- continue;
111
- }
112
- const r = rcEdit.removeBlock({ shell: rc.shell, file: rc.path });
113
- if (r.changed) {
114
- log.info(`uninstall: removed managed block from ${rc.path} (${rc.shell})`);
115
- removed.push(rc.path);
116
- } else {
117
- // X6 fix: previously silent. Surface "checked, nothing to remove"
118
- // so the user can tell the rc file was inspected — Owen's #discord
119
- // 2026-05-12 Windows report only logged "powershell-legacy absent"
120
- // and never mentioned the modern powershell rc which existed but
121
- // had no managed block.
122
- log.info(`uninstall: no managed block in ${rc.path} (${rc.shell}) — skip`);
123
- }
124
- }
125
- return { removed };
126
- }
127
-
128
- module.exports = { run, uninject, fallbackRc };
98
+ module.exports = { run, fallbackRc };
@@ -48,6 +48,52 @@ function shellEscapePsSQ(v) {
48
48
  return String(v).replace(/'/g, "''");
49
49
  }
50
50
 
51
+ // agentWrapPosix is a tiny bash/zsh-portable shim that prints a
52
+ // one-line "skalpel active/inactive" hint right before launching
53
+ // `claude`. The status is computed by `skalpel status` which checks
54
+ // both the daemon socket AND the compressor toggle in config.toml so
55
+ // the line accurately reflects whether anything will happen on the
56
+ // next request.
57
+ //
58
+ // Self-disabling knobs:
59
+ // - SKALPEL_NO_AGENT_WRAP=1 → operator opt-out (env var, anywhere).
60
+ // - existing `claude` alias → we don't override aliases.
61
+ // - `skalpel` not on PATH → wrapper installs nothing.
62
+ const agentWrapPosix = `
63
+ # Pre-launch status hint for coding agents launched in this shell.
64
+ # Status comes from \`skalpel status\` (checks daemon + engine config).
65
+ # Set SKALPEL_NO_AGENT_WRAP=1 to disable.
66
+ if [ -z "\${SKALPEL_NO_AGENT_WRAP:-}" ] && ! alias claude >/dev/null 2>&1 && command -v skalpel >/dev/null 2>&1; then
67
+ claude() { skalpel status >&2; command claude "$@"; }
68
+ fi`;
69
+
70
+ // agentWrapFish is the fish-shell port of agentWrapPosix.
71
+ const agentWrapFish = `
72
+ # Pre-launch status hint for coding agents launched in this shell.
73
+ # Status comes from \`skalpel status\` (checks daemon + engine config).
74
+ # Set SKALPEL_NO_AGENT_WRAP=1 to disable.
75
+ if not set -q SKALPEL_NO_AGENT_WRAP; and not functions -q claude; and command -q skalpel
76
+ function claude
77
+ skalpel status 1>&2
78
+ command claude $argv
79
+ end
80
+ end`;
81
+
82
+ // agentWrapPosh is the PowerShell port. Same delegation: ask the
83
+ // `skalpel` binary for the status, write it to stderr, then forward.
84
+ const agentWrapPosh = `
85
+ # Pre-launch status hint for coding agents launched in this shell.
86
+ # Status comes from \`skalpel status\` (checks daemon + engine config).
87
+ # Set $env:SKALPEL_NO_AGENT_WRAP=1 to disable.
88
+ $_skalpelOrigClaude = Get-Command claude.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
89
+ $_skalpelStatusBin = Get-Command skalpel -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
90
+ if (-not $env:SKALPEL_NO_AGENT_WRAP -and $_skalpelOrigClaude -and $_skalpelStatusBin) {
91
+ function global:claude {
92
+ & $script:_skalpelStatusBin.Source status 1>&2
93
+ & $script:_skalpelOrigClaude.Source @args
94
+ }
95
+ }`;
96
+
51
97
  function bodyFor(shell, env) {
52
98
  const note =
53
99
  'This block is managed by skalpel install. Do not edit by hand;\n' +
@@ -59,6 +105,7 @@ function bodyFor(shell, env) {
59
105
  ...Object.entries(env).map(
60
106
  ([k, v]) => `set -gx ${k} "${shellEscapePosixDQ(v)}"`
61
107
  ),
108
+ agentWrapFish,
62
109
  ].join('\n');
63
110
  case 'powershell':
64
111
  case 'powershell-legacy':
@@ -67,6 +114,7 @@ function bodyFor(shell, env) {
67
114
  ...Object.entries(env).map(
68
115
  ([k, v]) => `$env:${k} = '${shellEscapePsSQ(v)}'`
69
116
  ),
117
+ agentWrapPosh,
70
118
  ].join('\n');
71
119
  default:
72
120
  return [
@@ -74,6 +122,7 @@ function bodyFor(shell, env) {
74
122
  ...Object.entries(env).map(
75
123
  ([k, v]) => `export ${k}="${shellEscapePosixDQ(v)}"`
76
124
  ),
125
+ agentWrapPosix,
77
126
  ].join('\n');
78
127
  }
79
128
  }
@@ -122,23 +171,6 @@ function applyBlock({ shell, file, env, dryRun }) {
122
171
  return { changed: true };
123
172
  }
124
173
 
125
- function removeBlock({ shell, file, dryRun }) {
126
- if (!fs.existsSync(file)) {
127
- return { changed: false, missing: true };
128
- }
129
- const current = fs.readFileSync(file, 'utf8');
130
- const re = blockRegex(shell);
131
- const next = current.replace(re, '');
132
- if (current === next) {
133
- return { changed: false };
134
- }
135
- if (dryRun) {
136
- return { changed: true, dryRun: true };
137
- }
138
- fs.writeFileSync(file, next);
139
- return { changed: true };
140
- }
141
-
142
174
  // xmlEscape: B47 — used by Task.xml renderer.
143
175
  function xmlEscape(v) {
144
176
  return String(v)
@@ -154,7 +186,6 @@ module.exports = {
154
186
  buildBlock,
155
187
  rewrite,
156
188
  applyBlock,
157
- removeBlock,
158
189
  fenceFor,
159
190
  bodyFor,
160
191
  shellEscapePosixDQ,
@@ -145,15 +145,17 @@ function run() {
145
145
  }
146
146
  });
147
147
 
148
- test('TestRcEdit_RemoveBlock_Leaves_User_Content', () => {
149
- const file = tmpFile('rcedit-remove');
148
+ test('TestRcEdit_ApplyBlock_Leaves_User_Content_Untouched', () => {
149
+ // Install-side counterpart to the old removeBlock test: apply the
150
+ // managed block and confirm the user's own export line still
151
+ // exists in the rewritten file. (Removal lives in Go now;
152
+ // see internal/cleanup/rcedit for its parity tests.)
153
+ const file = tmpFile('rcedit-apply-user');
150
154
  fs.writeFileSync(file, 'export USER_VAR=1\n');
151
155
  try {
152
156
  rc.applyBlock({ shell: 'bash', file, env: rc.envBlockValues(7878) });
153
- const r = rc.removeBlock({ shell: 'bash', file });
154
- assert.strictEqual(r.changed, true);
155
157
  const after = fs.readFileSync(file, 'utf8');
156
- assert.ok(!after.includes(rc.FENCE_BEGIN_POSIX));
158
+ assert.ok(after.includes(rc.FENCE_BEGIN_POSIX), 'managed block applied');
157
159
  assert.ok(after.includes('export USER_VAR=1'), 'user var preserved');
158
160
  } finally {
159
161
  fs.unlinkSync(file);
@@ -99,29 +99,6 @@ function loadCommands() {
99
99
  }
100
100
  }
101
101
 
102
- function unloadCommands() {
103
- switch (process.platform) {
104
- case 'darwin': {
105
- const target = launchdTarget();
106
- const fullTarget =
107
- target === 'system' ? 'system/ai.skalpel.daemon' : `${target}/ai.skalpel.daemon`;
108
- return [
109
- [LAUNCHCTL_PATH, ['bootout', fullTarget], { allowFail: true }],
110
- ];
111
- }
112
- case 'win32': {
113
- const ps1 = renderedPowerShellPath();
114
- return [
115
- ['powershell.exe', ['-NoProfile', '-File', ps1, '-Action', 'unregister'], { allowFail: true }],
116
- ];
117
- }
118
- default:
119
- return [
120
- ['systemctl', ['--user', 'disable', '--now', 'skalpel-daemon.service'], { allowFail: true }],
121
- ];
122
- }
123
- }
124
-
125
102
  // B42: validate the daemon binary exists at the path the service
126
103
  // unit will exec. SHA256 verification is deferred (TODO below) until
127
104
  // dist.shasum is populated in package.json. The existence check is
@@ -161,121 +138,6 @@ function verifyBinary() {
161
138
  return { bin, stat };
162
139
  }
163
140
 
164
- // fallbackUnloadCommands returns commands that do not depend on
165
- // installed-state files. Used when the per-OS primary unload path
166
- // requires a precondition (rendered PS1, etc.) that may be missing —
167
- // e.g. an aborted install or a prior uninstall already deleted it.
168
- function fallbackUnloadCommands() {
169
- switch (process.platform) {
170
- case 'win32':
171
- // schtasks.exe lives in System32 and is always present; the
172
- // PS1 helper only existed to template params, so we invoke
173
- // schtasks directly when the helper is gone.
174
- return [
175
- ['schtasks.exe', ['/End', '/TN', 'Skalpel\\skalpel-daemon'], { allowFail: true }],
176
- ['schtasks.exe', ['/Delete', '/TN', 'Skalpel\\skalpel-daemon', '/F'], { allowFail: true }],
177
- ];
178
- default:
179
- return [];
180
- }
181
- }
182
-
183
- // Preflight + three-state outcome tracking. We distinguish:
184
- // - succeeded: command exited 0
185
- // - skipped-precondition: a file the command needs is absent
186
- // - failed-allowed: command exited non-0, allowFail=true
187
- // - failed-hard: command exited non-0, allowFail=false
188
- // The pre-X1 code lumped succeeded + failed-allowed into "ok",
189
- // producing "1/1 ok" reports when an unregister silently failed
190
- // because the service was never registered to begin with. Owen
191
- // (#discord, 2026-05-12) saw this on Windows when the rendered PS1
192
- // helper was gone and PowerShell errored loudly while the wizard
193
- // still reported success.
194
- function unregister({ dryRun }) {
195
- const dest = paths.servicePath();
196
- const ps1 = renderedPowerShellPath();
197
- const servicePresent = fs.existsSync(dest);
198
- const ps1Present = fs.existsSync(ps1);
199
-
200
- if (dryRun) {
201
- log.dryRun(`uninstall: would unregister service at ${dest}`);
202
- if (!servicePresent) {
203
- log.dryRun(` service file absent at ${dest} — would skip unload commands`);
204
- } else {
205
- const cmds = (process.platform === 'win32' && !ps1Present)
206
- ? fallbackUnloadCommands()
207
- : unloadCommands();
208
- for (const [bin, args] of cmds) {
209
- log.dryRun(` would run: ${bin} ${args.join(' ')}`);
210
- }
211
- }
212
- log.dryRun(` would rm ${dest}`);
213
- if (process.platform === 'win32') {
214
- log.dryRun(` would rm ${ps1}`);
215
- }
216
- return { dryRun: true, succeeded: 0, skipped: 0, failedAllowed: 0, serviceFileRemoved: false };
217
- }
218
-
219
- let cmds;
220
- if (!servicePresent) {
221
- log.info(`uninstall: service file absent at ${dest} — nothing registered to unload`);
222
- cmds = [];
223
- } else if (process.platform === 'win32' && !ps1Present) {
224
- log.warn(`uninstall: PowerShell helper absent at ${ps1} — using direct schtasks fallback`);
225
- cmds = fallbackUnloadCommands();
226
- } else {
227
- cmds = unloadCommands();
228
- }
229
-
230
- let succeeded = 0;
231
- let failedAllowed = 0;
232
- const errors = [];
233
-
234
- for (const [bin, args, opts] of cmds) {
235
- const allowFail = !!(opts && opts.allowFail);
236
- const r = spawnSync(bin, args, { stdio: 'inherit' });
237
- if (r.status === 0) {
238
- succeeded += 1;
239
- } else if (allowFail) {
240
- failedAllowed += 1;
241
- log.info(`uninstall: ${bin} ${args.join(' ')} exited ${r.status} (allowed)`);
242
- } else {
243
- const msg = `uninstall: ${bin} ${args.join(' ')} exited ${r.status}`;
244
- errors.push(msg);
245
- log.warn(`${msg}; rolling forward to file removal`);
246
- }
247
- }
248
-
249
- if (cmds.length === 0) {
250
- log.info('uninstall: phase 2 skipped — service was not registered');
251
- } else {
252
- log.info(
253
- `uninstall: phase 2 complete: ${succeeded} succeeded, ` +
254
- `${failedAllowed} failed (allowed)` +
255
- (errors.length ? `, ${errors.length} hard errors` : '')
256
- );
257
- }
258
-
259
- let serviceFileRemoved = false;
260
- if (servicePresent) {
261
- fs.unlinkSync(dest);
262
- log.info(`uninstall: removed ${dest}`);
263
- serviceFileRemoved = true;
264
- }
265
- if (process.platform === 'win32' && ps1Present) {
266
- fs.unlinkSync(ps1);
267
- log.info(`uninstall: removed ${ps1}`);
268
- }
269
- return {
270
- removed: serviceFileRemoved,
271
- serviceFileRemoved,
272
- succeeded,
273
- skipped: cmds.length === 0 ? 1 : 0,
274
- failedAllowed,
275
- errors,
276
- };
277
- }
278
-
279
141
  function run({ dryRun }) {
280
142
  const tmpl = templatePath();
281
143
  const dest = paths.servicePath();
@@ -351,4 +213,4 @@ function run({ dryRun }) {
351
213
  return { skipped: false, registered: true, warnings };
352
214
  }
353
215
 
354
- module.exports = { run, unregister, templatePath, loadCommands, unloadCommands, fallbackUnloadCommands, launchdTarget };
216
+ module.exports = { run, templatePath, loadCommands, launchdTarget };
@@ -9,7 +9,8 @@ After=default.target
9
9
  [Service]
10
10
  Type=simple
11
11
  ExecStart="{{BIN}}/skalpeld" --service-mode
12
- Restart=on-failure
12
+ Restart=always
13
+ StartLimitBurst=5
13
14
  RestartSec=10s
14
15
 
15
16
  # Process hygiene
@@ -26,7 +26,7 @@
26
26
  <AllowHardTerminate>false</AllowHardTerminate>
27
27
  <RestartOnFailure>
28
28
  <Interval>PT10S</Interval>
29
- <Count>3</Count>
29
+ <Count>9999</Count>
30
30
  </RestartOnFailure>
31
31
  <Hidden>true</Hidden>
32
32
  <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>