gentle-pi 3.5.0 → 3.6.0

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.
@@ -4,7 +4,21 @@
4
4
  // resolution, pi resolution order, the version gate, and the pi invocation —
5
5
  // lives in that pure, unit-tested module; this file only wires it to the real
6
6
  // process, filesystem, and child process.
7
- import { accessSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import {
8
+ accessSync,
9
+ closeSync,
10
+ constants as fsConstants,
11
+ existsSync,
12
+ mkdirSync,
13
+ openSync,
14
+ readdirSync,
15
+ readFileSync,
16
+ realpathSync,
17
+ renameSync,
18
+ rmSync,
19
+ statSync,
20
+ writeFileSync,
21
+ } from "node:fs";
8
22
  import { createRequire } from "node:module";
9
23
  import { constants as osConstants, homedir } from "node:os";
10
24
  import { delimiter, dirname, join, resolve as resolvePath } from "node:path";
@@ -13,18 +27,34 @@ import { fileURLToPath } from "node:url";
13
27
  import {
14
28
  buildPiInvocation,
15
29
  checkPiVersion,
30
+ decideTakeOver,
16
31
  describeVersion,
32
+ discoverLooseExtensionEntries,
33
+ findGentlePiDeclaration,
34
+ forceJsonFieldIfAbsentInOriginal,
17
35
  helpText,
36
+ homeSelectorFlags,
37
+ isSetupCapablePin,
18
38
  launcherConfigPath,
39
+ MIN_SETUP_GENTLE_AI_VERSION,
19
40
  missingPiMessage,
41
+ needsProvisioning,
42
+ otherPackageInjections,
20
43
  parseLauncherArgs,
21
44
  parseLauncherConfig,
45
+ parseRawLauncherConfig,
22
46
  planSpawn,
47
+ POST_INSTALL_REMOVAL_SOURCES,
48
+ postInstallRemovals,
49
+ provisionedEntry,
50
+ recordProvisioned,
23
51
  resolveHome,
24
52
  resolvePiRuntime,
25
- settingsDeclareGentlePi,
53
+ restoreJsonField,
54
+ shellQuote,
26
55
  } from "../runtime/gentle-shell-launcher.mjs";
27
- import { installIsolatedTuiModeSetting } from "../scripts/install-tui-mode-setting.mjs";
56
+ import { GENTLE_AI_VERSION, gentleAiBinaryPath, PackageLocalGentleAiBinaryMissingError } from "../runtime/gentle-ai-binary.mjs";
57
+ import { DEFAULT_THEME_NAME, installIsolatedTuiModeSetting } from "../scripts/install-tui-mode-setting.mjs";
28
58
 
29
59
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
30
60
 
@@ -83,12 +113,154 @@ function ownPackageVersion() {
83
113
  }
84
114
 
85
115
  function emptyArgs() {
86
- return { link: false, isolated: false, home: undefined, help: false, version: false, command: undefined, commandArgs: [], passthrough: [], error: undefined };
116
+ return {
117
+ link: false,
118
+ isolated: false,
119
+ home: undefined,
120
+ packageRoot: undefined,
121
+ help: false,
122
+ version: false,
123
+ command: undefined,
124
+ commandArgs: [],
125
+ passthrough: [],
126
+ piSubcommand: undefined,
127
+ error: undefined,
128
+ };
129
+ }
130
+
131
+ // package.json "name" reader injected into findGentlePiDeclaration: a
132
+ // missing or unreadable package.json, or a non-string "name", is never an
133
+ // error here — it just means that path package is not gentle-pi.
134
+ function readPackageName(dir) {
135
+ try {
136
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
137
+ return typeof pkg.name === "string" ? pkg.name : undefined;
138
+ } catch {
139
+ return undefined;
140
+ }
141
+ }
142
+
143
+ // Best-effort realpath: a directory that does not exist (yet, or ever)
144
+ // cannot be realpath'd, so the take-over decision falls back to comparing
145
+ // the raw path instead of failing.
146
+ function safeRealpath(path) {
147
+ try {
148
+ return realpathSync(path);
149
+ } catch {
150
+ return path;
151
+ }
152
+ }
153
+
154
+ // Used to filter the loose extension dirs a take-over re-injects: a missing
155
+ // path, or one that is not a directory (for example a stray file named
156
+ // "extensions"), is silently excluded rather than passed to pi as -e.
157
+ function isDirectory(path) {
158
+ try {
159
+ return statSync(path).isDirectory();
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+
165
+ // Real-fs adapter for discoverLooseExtensionEntries (lib/gentle-shell-launcher.ts):
166
+ // statSync-based isFile/isDirectory (not readdirSync's Dirent, which uses
167
+ // lstat and so would treat a symlinked file or directory as neither) so a
168
+ // symlinked loose extension resolves the same way pi's own fs.existsSync-based
169
+ // checks would.
170
+ const looseExtensionFs = {
171
+ readdir(dir) {
172
+ let names;
173
+ try {
174
+ names = readdirSync(dir);
175
+ } catch (error) {
176
+ // resolveLooseExtensionEntries only calls this once isDirectory(dir)
177
+ // has already confirmed the directory exists, so a failure here (for
178
+ // example EACCES) is a real read failure, not a missing directory.
179
+ // Warn instead of silently dropping every loose extension it would
180
+ // have contributed (R4-loose-extension-enumeration-fails-silently).
181
+ process.stderr.write(`gentle-shell: could not read loose extension directory ${dir}: ${error.message} (skipping)\n`);
182
+ return [];
183
+ }
184
+ return names.map((name) => {
185
+ const entryPath = join(dir, name);
186
+ try {
187
+ const entryStat = statSync(entryPath);
188
+ return { name, isFile: entryStat.isFile(), isDirectory: entryStat.isDirectory() };
189
+ } catch {
190
+ return { name, isFile: false, isDirectory: false };
191
+ }
192
+ });
193
+ },
194
+ exists: existsSync,
195
+ };
196
+
197
+ // A loose extensions directory that is itself a self-contained extension —
198
+ // a package.json declaring a non-empty "pi.extensions" manifest — is passed
199
+ // through as a single -e <dir> instead of being broken into per-file
200
+ // entries: pi's own module loader (jiti) resolves that case directly,
201
+ // exactly as it would for any other explicitly configured package path. A
202
+ // root-level index.ts/index.js is deliberately NOT treated as that same
203
+ // marker: pi's own discovery loads it as just another loose file, so
204
+ // collapsing the whole directory on its presence silently dropped sibling
205
+ // loose files like extra.ts (R4-loose-index-collapses-sibling-extensions).
206
+ function readPiManifestExtensions(dir) {
207
+ try {
208
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
209
+ return Array.isArray(pkg?.pi?.extensions) ? pkg.pi.extensions : undefined;
210
+ } catch {
211
+ return undefined;
212
+ }
213
+ }
214
+
215
+ function looseDirHasOwnEntryPoint(dir) {
216
+ const manifestExtensions = readPiManifestExtensions(dir);
217
+ return manifestExtensions !== undefined && manifestExtensions.length > 0;
218
+ }
219
+
220
+ // Resolves one candidate loose-extensions directory (<agentDir>/extensions or
221
+ // <cwd>/.pi/extensions) into the -e entries a take-over must re-inject: the
222
+ // directory itself when it is a self-contained extension, otherwise every
223
+ // loose file discoverLooseExtensionEntries finds inside it. A missing or
224
+ // non-directory candidate resolves to no entries.
225
+ function resolveLooseExtensionEntries(dir) {
226
+ if (!isDirectory(dir)) return [];
227
+ if (looseDirHasOwnEntryPoint(dir)) return [dir];
228
+ return discoverLooseExtensionEntries(dir, looseExtensionFs);
229
+ }
230
+
231
+ // Test/development-only override for the launcher config.json path
232
+ // (normally launcherConfigPath(homedir())). Lets a test — or the
233
+ // packed-artifact E2E script, which also needs `--link` probes against the
234
+ // real pi home and so cannot just redirect HOME wholesale — read and write
235
+ // the `home` subcommand's and the auto-provisioning marker's config file
236
+ // without ever touching the real ~/.gentle-shell/config.json. Never
237
+ // consulted outside these two call sites; see docs/readme-reference.md.
238
+ function resolveConfigPath() {
239
+ const override = process.env.GENTLE_SHELL_CONFIG;
240
+ return override !== undefined && override.length > 0 ? override : launcherConfigPath(homedir());
241
+ }
242
+
243
+ // Raw config.json as a plain object (see RawLauncherConfig in
244
+ // lib/gentle-shell-launcher.ts): unlike parseLauncherConfig, this preserves
245
+ // every key, so a write (home persistence, or the provisioning marker below)
246
+ // never drops a key it does not itself understand.
247
+ function readRawConfig(configPath) {
248
+ return parseRawLauncherConfig(readJsonIfExists(configPath));
249
+ }
250
+
251
+ // Atomic (temp file in the same directory, then rename): a crash or kill
252
+ // mid-write must never leave config.json truncated or partially written,
253
+ // since it also carries the S7 provisioning marker every plain launch reads.
254
+ function writeRawConfig(configPath, config) {
255
+ const configDir = dirname(configPath);
256
+ if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true, mode: 0o700 });
257
+ const tempPath = join(configDir, `.${basenameOf(configPath)}.gentle-shell-${process.pid}.tmp`);
258
+ writeFileSync(tempPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
259
+ renameSync(tempPath, configPath);
87
260
  }
88
261
 
89
262
  function loadConfig() {
90
- const configPath = launcherConfigPath(homedir());
91
- const text = readJsonIfExists(configPath);
263
+ const text = readJsonIfExists(resolveConfigPath());
92
264
  return text === undefined ? undefined : parseLauncherConfig(text);
93
265
  }
94
266
 
@@ -102,21 +274,775 @@ function handleHomeCommand(commandArgs) {
102
274
  const [value] = commandArgs;
103
275
  if (value.length === 0) fail("gentle-shell home requires a non-empty argument. Run 'gentle-shell --help'.", 2);
104
276
 
105
- const configPath = launcherConfigPath(homedir());
106
- const configDir = dirname(configPath);
107
- if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true, mode: 0o700 });
277
+ const configPath = resolveConfigPath();
278
+ const existing = readRawConfig(configPath);
108
279
 
109
280
  if (value === "link" || value === "isolated") {
110
- writeFileSync(configPath, `${JSON.stringify({ home: value }, null, 2)}\n`, "utf8");
281
+ writeRawConfig(configPath, { ...existing, home: value });
111
282
  process.stdout.write(`Saved home: ${value}\n`);
112
283
  process.exit(0);
113
284
  }
114
285
  const dir = resolvePath(value);
115
- writeFileSync(configPath, `${JSON.stringify({ home: dir }, null, 2)}\n`, "utf8");
286
+ writeRawConfig(configPath, { ...existing, home: dir });
116
287
  process.stdout.write(`Saved home: path ${dir}\n`);
117
288
  process.exit(0);
118
289
  }
119
290
 
291
+ // Test/development-only override for the setup subcommand's gentle-ai
292
+ // executable path. Lets a test point at a stub script (or a deliberately
293
+ // missing path) without touching the real pinned .gentle-ai/v<version>/gentle-ai
294
+ // install this package ships, and without needing to fake its release-asset
295
+ // integrity manifest. Never consulted outside `setup`; see docs/readme-reference.md.
296
+ function resolveSetupGentleAiBinary() {
297
+ const override = process.env.GENTLE_SHELL_GENTLE_AI_BIN;
298
+ return override !== undefined && override.length > 0 ? override : gentleAiBinaryPath();
299
+ }
300
+
301
+ // Test/development-only override for the setup subcommand's reported
302
+ // package-local gentle-ai pin. Lets a test simulate an older or newer pin
303
+ // without changing the real installed .gentle-ai/v<version> bundle. Never
304
+ // consulted outside `setup`; see docs/readme-reference.md.
305
+ function resolveSetupGentleAiPin() {
306
+ const override = process.env.GENTLE_SHELL_GENTLE_AI_PIN;
307
+ return override !== undefined && override.length > 0 ? override : GENTLE_AI_VERSION;
308
+ }
309
+
310
+ const SKIP_GENTLE_AI_INSTALL_ENV = "GENTLE_PI_SKIP_GENTLE_AI_INSTALL";
311
+
312
+ // Test/development-only override for the setup subcommand's self-heal
313
+ // installer script path. Lets a test point at a stub installer (one that
314
+ // creates the stub binary, or deliberately doesn't) instead of running the
315
+ // real node scripts/install-gentle-ai.mjs, whose supply-chain integrity
316
+ // checks (and real network download) a test cannot cheaply satisfy. Never
317
+ // consulted outside `setup`; see docs/readme-reference.md.
318
+ function resolveSetupGentleAiInstaller() {
319
+ const override = process.env.GENTLE_SHELL_GENTLE_AI_INSTALLER;
320
+ return override !== undefined && override.length > 0 ? override : join(packageRoot, "scripts", "install-gentle-ai.mjs");
321
+ }
322
+
323
+ // Spawns `command` and resolves once it exits, instead of exiting the
324
+ // process directly: the shared core the manual `setup` subcommand and the
325
+ // automatic first-run provisioning flow (S7) both drive, deciding for
326
+ // themselves whether to `process.exit` (setup) or warn and continue (auto
327
+ // mode). `stdio` lets a silent caller route the child's stdout/stderr to the
328
+ // launcher's own stderr (see runSetupFlow) while a manual `setup` keeps the
329
+ // child's stdio inherited. A `signal` on the result records that the child
330
+ // exited via signal for any reason; `interrupted: true` additionally marks
331
+ // that it happened because *this launcher itself* received
332
+ // SIGINT/SIGTERM/SIGHUP and forwarded it — as opposed to the child dying by a
333
+ // signal entirely on its own (a crash, an OOM kill, an external `kill`),
334
+ // which is an ordinary failure, not a request to stop (R3-002). Only
335
+ // `interrupted` lets a caller skip printing remediation advice and abort the
336
+ // whole launch; a plain `signal` with no `interrupted` is treated like any
337
+ // other failure. `timeoutMs`, when given, kills the child and resolves with
338
+ // `timedOut: true` instead of waiting forever on a hung gentle-ai/pi
339
+ // invocation; only the automatic first-run flow passes it (see
340
+ // AUTO_SETUP_CHILD_TIMEOUT_MS below) — manual `setup` never times out.
341
+ function spawnAndWait(command, args, env, stdio, timeoutMs) {
342
+ return new Promise((resolve) => {
343
+ const launchPlan = planSpawn({ command, args, platform: process.platform });
344
+ const child = spawn(launchPlan.command, launchPlan.args, { stdio, env, shell: launchPlan.shell });
345
+ let timedOut = false;
346
+ let interrupted = false;
347
+ const timer = timeoutMs !== undefined ? setTimeout(() => {
348
+ timedOut = true;
349
+ child.kill("SIGTERM");
350
+ }, timeoutMs) : undefined;
351
+ const signalHandlers = ["SIGINT", "SIGTERM", "SIGHUP"].map((signal) => {
352
+ const handler = () => {
353
+ interrupted = true;
354
+ child.kill(signal);
355
+ };
356
+ process.on(signal, handler);
357
+ return [signal, handler];
358
+ });
359
+ const cleanup = () => {
360
+ for (const [signal, handler] of signalHandlers) process.removeListener(signal, handler);
361
+ if (timer !== undefined) clearTimeout(timer);
362
+ };
363
+ child.on("error", (error) => {
364
+ cleanup();
365
+ resolve({ ok: false, exitCode: 1, error });
366
+ });
367
+ child.on("exit", (code, signal) => {
368
+ cleanup();
369
+ if (timedOut) {
370
+ resolve({ ok: false, exitCode: 1, timedOut: true });
371
+ return;
372
+ }
373
+ if (signal) {
374
+ resolve(
375
+ interrupted
376
+ ? { ok: false, exitCode: signalExitCode(signal), signal, interrupted: true }
377
+ : { ok: false, exitCode: signalExitCode(signal), signal },
378
+ );
379
+ return;
380
+ }
381
+ const exitCode = code ?? 1;
382
+ resolve({ ok: exitCode === 0, exitCode });
383
+ });
384
+ });
385
+ }
386
+
387
+ // Renders `ms` as a human ceiling for a "timed out after ..." message: whole
388
+ // minutes when `ms` is an exact multiple of 60000 (matching the production
389
+ // 15-minute default and any operator-chosen whole-minute override), seconds
390
+ // otherwise — including the sub-second overrides
391
+ // GENTLE_SHELL_AUTO_SETUP_TIMEOUT_MS sets in tests. Used at every "timed out
392
+ // after ..." call site instead of a hardcoded "15 minutes"
393
+ // (R2-timeout-message-hardcoded), so the message always reflects the ceiling
394
+ // that actually fired.
395
+ function formatTimeoutCeiling(ms) {
396
+ if (ms % 60000 === 0) {
397
+ const minutes = ms / 60000;
398
+ return `${minutes} minute${minutes === 1 ? "" : "s"}`;
399
+ }
400
+ const seconds = ms / 1000;
401
+ return `${seconds} second${seconds === 1 ? "" : "s"}`;
402
+ }
403
+
404
+ // Self-heals a missing package-local gentle-ai binary before the setup flow
405
+ // gives up on it. `npm install -g <tarball>` on a machine whose npm config
406
+ // disables lifecycle scripts (`ignore-scripts=true`, this maintainer's own
407
+ // machine included) never runs the package's own postinstall
408
+ // (scripts/install-gentle-ai.mjs), so .gentle-ai/v<pin>/gentle-ai is missing
409
+ // even though the package itself installed fine. Running that same
410
+ // installer here recovers it: it downloads the pinned, sha256-verified
411
+ // release asset, exactly as postinstall would have. Skipped when
412
+ // GENTLE_PI_SKIP_GENTLE_AI_INSTALL is "1" — the same variable that already
413
+ // controls whether real postinstall provisioning runs (see
414
+ // docs/readme-reference.md) — in which case today's plain missing-binary
415
+ // failure is kept, with the variable named in the message. Returns
416
+ // {ok, exitCode, message} instead of exiting the process, so the caller
417
+ // decides whether to exit (manual setup) or warn and continue (auto mode).
418
+ //
419
+ // Signal-contract note (R2-signal-contract-cleanup-gap): unlike the two
420
+ // children spawnAndWait drives during this flow (the package-local gentle-ai
421
+ // binary in runSetupFlow, and each `pi remove` in removePostInstallSources),
422
+ // this installer runs via a *synchronous* spawnSync with no timeout and no
423
+ // launcher-interrupt tracking. A SIGINT/SIGTERM/SIGHUP reaching the launcher
424
+ // while this specific call is blocking falls back to Node's default signal
425
+ // disposition (the launcher exits immediately) instead of the
426
+ // interrupted-vs-ordinary-failure distinction spawnAndWait's callers get. The
427
+ // installer script itself is small, fast, and non-interactive in practice, so
428
+ // this gap is accepted rather than converting it to the async, timeout-bound
429
+ // spawnAndWait path.
430
+ function ensurePackageLocalGentleAi(binaryPath, pinnedVersion, stdio) {
431
+ if (existsSync(binaryPath)) return { ok: true };
432
+ if (process.env[SKIP_GENTLE_AI_INSTALL_ENV] === "1") {
433
+ return {
434
+ ok: false,
435
+ exitCode: 1,
436
+ message: `${new PackageLocalGentleAiBinaryMissingError(binaryPath).message} (${SKIP_GENTLE_AI_INSTALL_ENV} is set; not installing it automatically)`,
437
+ };
438
+ }
439
+ process.stderr.write(
440
+ `gentle-shell: the package-local gentle-ai v${pinnedVersion} is missing (npm lifecycle scripts may be disabled); installing it now\n`,
441
+ );
442
+ const installerPath = resolveSetupGentleAiInstaller();
443
+ const result = spawnSync(process.execPath, [installerPath], { stdio });
444
+ if (result.error) {
445
+ return { ok: false, exitCode: 1, message: `Could not run the gentle-ai installer at ${installerPath}: ${result.error.message}` };
446
+ }
447
+ if (!existsSync(binaryPath)) return { ok: false, exitCode: 1, message: new PackageLocalGentleAiBinaryMissingError(binaryPath).message };
448
+ return { ok: true };
449
+ }
450
+
451
+ // Shared env for the gentle-ai install spawn and the pi remove cleanup spawn
452
+ // below: PI_CODING_AGENT_DIR/GENTLE_PI_AGENT_HOME point both at the resolved
453
+ // home, and the resolved pi runtime's directory is prepended to PATH so
454
+ // gentle-ai's (or pi's own) preflight finds `pi` even when it is bundled or
455
+ // given through GENTLE_SHELL_PI.
456
+ function buildSetupEnv(home, runtime) {
457
+ return {
458
+ ...process.env,
459
+ PI_CODING_AGENT_DIR: home.dir,
460
+ GENTLE_PI_AGENT_HOME: home.dir,
461
+ PATH: `${dirname(runtime.command)}${delimiter}${process.env.PATH ?? ""}`,
462
+ };
463
+ }
464
+
465
+ // The shared Pi persona file gentle-ai writes on every install, regardless
466
+ // of the target home: its own PiPersonaConfigPath always resolves against
467
+ // the OS home, never PI_CODING_AGENT_DIR (gentle-ai internal/components/persona/inject.go),
468
+ // so a `setup` run for any home silently resets whatever persona mode the
469
+ // user already chose back to gentle-ai's default preset unless something
470
+ // snapshots and restores it. See snapshotFile/restoreFile below and
471
+ // docs/readme-reference.md's setup "Known limitation".
472
+ function sharedPersonaPath() {
473
+ return join(homedir(), ".pi", "gentle-ai", "persona.json");
474
+ }
475
+
476
+ // Records `path`'s current state before a child process that might rewrite
477
+ // it runs: whether it exists, and if so its exact bytes and mode. Returns
478
+ // `{ path, existed: false }` for a missing file so restoreFile below knows
479
+ // to delete rather than rewrite it. Any error other than "does not exist"
480
+ // propagates — a snapshot that silently treats a permissions error as
481
+ // "missing" would then delete a file it never actually read.
482
+ function snapshotFile(path) {
483
+ try {
484
+ const bytes = readFileSync(path);
485
+ const mode = statSync(path).mode & 0o777;
486
+ return { path, existed: true, bytes, mode };
487
+ } catch (error) {
488
+ if (error.code === "ENOENT") return { path, existed: false };
489
+ throw error;
490
+ }
491
+ }
492
+
493
+ // Restores `snapshot` after the child that might have rewritten it exits,
494
+ // but only when its current state actually differs from what was recorded:
495
+ // a changed existing file is rewritten atomically (temp file in the same
496
+ // directory, then renamed, so a crash mid-restore never leaves a partial
497
+ // file) preserving the original mode; a file that did not exist before is
498
+ // removed if the child created one. Returns true when a restore/removal
499
+ // actually happened, so the caller prints exactly one notice.
500
+ function restoreFile(snapshot) {
501
+ const { path, existed } = snapshot;
502
+ if (!existed) {
503
+ if (!existsSync(path)) return false;
504
+ rmSync(path, { force: true });
505
+ return true;
506
+ }
507
+ let currentBytes;
508
+ try {
509
+ currentBytes = readFileSync(path);
510
+ } catch (error) {
511
+ if (error.code !== "ENOENT") throw error;
512
+ }
513
+ if (currentBytes !== undefined && currentBytes.equals(snapshot.bytes)) return false;
514
+ mkdirSync(dirname(path), { recursive: true });
515
+ const tempPath = join(dirname(path), `.${basenameOf(path)}.gentle-shell-restore-${process.pid}.tmp`);
516
+ writeFileSync(tempPath, snapshot.bytes, { mode: snapshot.mode });
517
+ renameSync(tempPath, path);
518
+ return true;
519
+ }
520
+
521
+ function basenameOf(path) {
522
+ const parts = path.split(/[\\/]/);
523
+ return parts[parts.length - 1];
524
+ }
525
+
526
+ // gentle-ai records the running binary's managed-asset bundle digest in the
527
+ // shared `~/.gentle-ai/state.json`, field managed_asset_digest (gentle-ai
528
+ // internal/cli/run.go, internal/state/state.go), regardless of which home it
529
+ // was installing into — same shared-file-outside-PI_CODING_AGENT_DIR problem
530
+ // as sharedPersonaPath above. The pinned package-local gentle-ai this setup
531
+ // flow spawns writes its own digest there, so afterward the user's own
532
+ // (unrelated, on-PATH) gentle-ai reports its managed assets as outdated and
533
+ // demands `gentle-ai sync`, even though nothing about the user's install
534
+ // changed. Unlike persona.json, state.json also carries fields the pinned
535
+ // gentle-ai is supposed to update (for example installed_agents), so this
536
+ // restores only the managed_asset_digest field via restoreJsonField
537
+ // (lib/gentle-shell-launcher.ts) instead of snapshotting the whole file. See
538
+ // docs/readme-reference.md's setup "Known limitation".
539
+ function sharedGentleAiStatePath() {
540
+ return join(homedir(), ".gentle-ai", "state.json");
541
+ }
542
+
543
+ const MANAGED_ASSET_DIGEST_FIELD = "managed_asset_digest";
544
+
545
+ // Reads state.json's raw text before the gentle-ai spawn that might rewrite
546
+ // it, tolerating a missing or unparsable file by returning undefined: unlike
547
+ // snapshotFile (persona.json above), there is nothing worth restoring later
548
+ // in that case, so the caller skips the restore step entirely rather than
549
+ // treating "missing" as its own snapshot state.
550
+ function readParsableJsonText(path) {
551
+ const text = readJsonIfExists(path);
552
+ if (text === undefined) return undefined;
553
+ try {
554
+ JSON.parse(text);
555
+ } catch {
556
+ return undefined;
557
+ }
558
+ return text;
559
+ }
560
+
561
+ // Restores managed_asset_digest in state.json after the gentle-ai spawn.
562
+ // Deliberately does NOT delete or otherwise touch a state.json the child
563
+ // created where none existed before (unlike restoreFile's whole-file
564
+ // persona.json handling): state.json is the user's own gentle-ai global
565
+ // state file, not something gentle-shell owns end-to-end, so removing one
566
+ // the tool just created would destroy state fields unrelated to this fix —
567
+ // `originalText` is undefined for that case (see readParsableJsonText
568
+ // above), and this returns early without reading or writing anything.
569
+ // Returns true when it actually wrote a restored file, so the caller prints
570
+ // exactly one notice.
571
+ function restoreManagedAssetDigestField(path, originalText) {
572
+ if (originalText === undefined) return false;
573
+ const currentText = readJsonIfExists(path);
574
+ if (currentText === undefined) return false;
575
+ const restoredText = restoreJsonField(originalText, currentText, MANAGED_ASSET_DIGEST_FIELD);
576
+ if (restoredText === undefined) return false;
577
+ const mode = statSync(path).mode & 0o777;
578
+ const tempPath = join(dirname(path), `.${basenameOf(path)}.gentle-shell-restore-${process.pid}.tmp`);
579
+ writeFileSync(tempPath, restoredText, { mode });
580
+ renameSync(tempPath, path);
581
+ return true;
582
+ }
583
+
584
+ // Restores the home's own settings.json "theme" field to whatever it was
585
+ // right before the gentle-ai spawn (`originalSettingsText`) — gentle-ai's
586
+ // managed install may write its own theme into settings.json, which would
587
+ // otherwise silently replace the theme Gentle Shell had going in. This is a
588
+ // field-level snapshot/restore around the spawn, not a "only act if there
589
+ // was no theme before" check: on a brand-new home, the isolated-home
590
+ // bootstrap (installIsolatedTuiModeSetting, withIsolatedHomeDefaults in
591
+ // scripts/install-tui-mode-setting.mjs) already wrote DEFAULT_THEME_NAME
592
+ // into settings.json before this snapshot is taken, so `originalSettingsText`
593
+ // already declares a theme there too — a check that skipped restoring
594
+ // whenever the original had a theme would never fire on a fresh home and let
595
+ // gentle-ai's own theme win. When the original truly declared a theme
596
+ // (either that bootstrap default, or the user's own earlier choice),
597
+ // whatever gentle-ai changed it to afterward is restored via the pure
598
+ // restoreJsonField (lib/gentle-shell-launcher.ts). When the original had no
599
+ // theme at all, there is nothing to restore, so DEFAULT_THEME_NAME is forced
600
+ // instead via forceJsonFieldIfAbsentInOriginal, so a home still ends up
601
+ // themed. Unlike the persona/state restores above, this is not a shared file
602
+ // outside the home — it is the home's own settings.json, so no whole-file
603
+ // snapshot/restore pairing is needed, only a before/after comparison.
604
+ // Returns "restored" or "forced" when it actually wrote the file (so the
605
+ // caller can print the matching notice), or false when nothing changed.
606
+ function enforceDefaultThemeField(settingsPath, originalSettingsText) {
607
+ if (originalSettingsText === undefined) return false;
608
+ const currentText = readJsonIfExists(settingsPath);
609
+ if (currentText === undefined) return false;
610
+ let originalHadTheme;
611
+ try {
612
+ const originalValue = JSON.parse(originalSettingsText);
613
+ originalHadTheme = typeof originalValue === "object" && originalValue !== null && !Array.isArray(originalValue) && Object.prototype.hasOwnProperty.call(originalValue, "theme");
614
+ } catch {
615
+ return false;
616
+ }
617
+ const newText = originalHadTheme
618
+ ? restoreJsonField(originalSettingsText, currentText, "theme")
619
+ : forceJsonFieldIfAbsentInOriginal(originalSettingsText, currentText, "theme", DEFAULT_THEME_NAME);
620
+ if (newText === undefined) return false;
621
+ const mode = statSync(settingsPath).mode & 0o777;
622
+ const tempPath = join(dirname(settingsPath), `.${basenameOf(settingsPath)}.gentle-shell-restore-${process.pid}.tmp`);
623
+ writeFileSync(tempPath, newText, { mode });
624
+ renameSync(tempPath, settingsPath);
625
+ return originalHadTheme ? "restored" : "forced";
626
+ }
627
+
628
+ // Never let a snapshot/restore step itself abort setup: a persona.json or
629
+ // state.json this launcher cannot read or write for an unexpected reason
630
+ // (EACCES, ENOSPC, a path that turned into a directory, ...) must not crash
631
+ // `gentle-shell setup` or block the automatic first-run flow from still
632
+ // launching pi — it only means that one file's shared-state protection did
633
+ // not apply this run. `label` and `path` identify what failed to the user;
634
+ // the caller decides what "safe" default to fall back to.
635
+ function safely(label, path, fallback, fn) {
636
+ try {
637
+ return fn();
638
+ } catch (error) {
639
+ process.stderr.write(`gentle-shell: could not ${label} at ${path} (${error.message}); continuing\n`);
640
+ return fallback;
641
+ }
642
+ }
643
+
644
+ // Provisions `home` with everything `gentle-ai install --agent pi` installs
645
+ // into a regular Pi, by spawning the package-local pinned gentle-ai binary
646
+ // (never a PATH `gentle-ai`) with PI_CODING_AGENT_DIR/GENTLE_PI_AGENT_HOME set
647
+ // to `home.dir` and the resolved pi runtime's directory prepended to PATH, so
648
+ // gentle-ai's own preflight finds `pi` even when it is bundled or given
649
+ // through GENTLE_SHELL_PI, then removes any conflicting package it declared
650
+ // (see runPostInstallCleanup below). `home` and `runtime` are resolved by
651
+ // the caller exactly as a normal run resolves them (including the
652
+ // isolated/--home bootstrap and the pi version gate). Precondition: the
653
+ // package-local gentle-ai pin must be at least MIN_SETUP_GENTLE_AI_VERSION —
654
+ // the first release that honors PI_CODING_AGENT_DIR here — or this refuses
655
+ // to spawn it, since an older pin would silently provision the caller's real
656
+ // ~/.pi/agent.
657
+ //
658
+ // Returns {ok, exitCode, message?} instead of exiting the process: the
659
+ // manual `setup` subcommand (handleSetupCommand) exits on the result, and
660
+ // the automatic first-run flow (maybeAutoProvisionHome, S7) warns and
661
+ // continues the launch on failure instead. `stdio` is threaded through to
662
+ // both child spawns unchanged (see spawnAndWait and
663
+ // ensurePackageLocalGentleAi above) — "inherit" for a manual `setup`, or
664
+ // `["ignore", 2, 2]` in auto mode so every child's stdout/stderr lands on
665
+ // this launcher's own stderr and its real stdout stays clean for `--mode
666
+ // rpc`/`-p` consumers. `timeoutMs` is threaded into every child this flow
667
+ // spawns (see spawnAndWait's own doc comment) — manual `setup` never passes
668
+ // it, so it never times out; the automatic flow does (S9).
669
+ async function runSetupFlow(home, runtime, { dryRun, stdio, timeoutMs }) {
670
+ const pinnedVersion = resolveSetupGentleAiPin();
671
+ if (!isSetupCapablePin(pinnedVersion)) {
672
+ return {
673
+ ok: false,
674
+ exitCode: 1,
675
+ message: `gentle-shell: setup needs the package-local gentle-ai v${MIN_SETUP_GENTLE_AI_VERSION} or newer (pinned: ${pinnedVersion}); this build cannot provision a home without touching ~/.pi/agent`,
676
+ };
677
+ }
678
+
679
+ const binaryPath = resolveSetupGentleAiBinary();
680
+ const ensured = ensurePackageLocalGentleAi(binaryPath, pinnedVersion, stdio);
681
+ if (!ensured.ok) return ensured;
682
+
683
+ process.stderr.write(`gentle-shell: provisioning ${home.dir} with the gentle-ai companion packages\n`);
684
+
685
+ const setupArgs = ["install", "--agent", "pi", "--scope", "global", ...(dryRun ? ["--dry-run"] : [])];
686
+ const env = buildSetupEnv(home, runtime);
687
+ const personaPath = sharedPersonaPath();
688
+ const personaSnapshot = safely("snapshot your Pi persona file", personaPath, undefined, () => snapshotFile(personaPath));
689
+ const statePath = sharedGentleAiStatePath();
690
+ const originalStateText = safely("read your Gentle AI state file", statePath, undefined, () => readParsableJsonText(statePath));
691
+ // Theme restore (never for --link — that home is the user's own
692
+ // pre-existing pi agent home — and never for a --dry-run, which must
693
+ // write nothing): snapshot the home's own settings.json theme right
694
+ // before the spawn, so enforceDefaultThemeField can restore it
695
+ // afterward if gentle-ai's managed install changed it — whether that
696
+ // theme was the isolated-home bootstrap's own default or the user's own
697
+ // earlier choice.
698
+ const trackTheme = !dryRun && home.mode !== "link";
699
+ const settingsPath = join(home.dir, "settings.json");
700
+ const originalSettingsText = trackTheme ? safely("read your Pi settings file", settingsPath, undefined, () => readParsableJsonText(settingsPath)) : undefined;
701
+ let installResult;
702
+ try {
703
+ installResult = await spawnAndWait(binaryPath, setupArgs, env, stdio, timeoutMs);
704
+ } finally {
705
+ if (personaSnapshot !== undefined && safely("restore your Pi persona file", personaPath, false, () => restoreFile(personaSnapshot))) {
706
+ process.stderr.write(`gentle-shell: kept your Pi persona unchanged (gentle-ai rewrote ${personaPath}; tracked upstream)\n`);
707
+ }
708
+ if (safely("restore your Gentle AI managed-asset record", statePath, false, () => restoreManagedAssetDigestField(statePath, originalStateText))) {
709
+ process.stderr.write(
710
+ `gentle-shell: kept your Gentle AI managed-asset record unchanged (the pinned gentle-ai rewrote ${statePath}; tracked upstream)\n`,
711
+ );
712
+ }
713
+ const themeOutcome = trackTheme ? safely("apply the default Gentle Shell theme", settingsPath, false, () => enforceDefaultThemeField(settingsPath, originalSettingsText)) : false;
714
+ if (themeOutcome === "forced") {
715
+ process.stderr.write(`gentle-shell: set the default ${DEFAULT_THEME_NAME} theme for ${home.dir} (no theme was set before this run)\n`);
716
+ } else if (themeOutcome === "restored") {
717
+ process.stderr.write(`gentle-shell: kept your Pi theme unchanged (gentle-ai rewrote ${settingsPath}; tracked upstream)\n`);
718
+ }
719
+ }
720
+ if (installResult.timedOut) {
721
+ return { ok: false, exitCode: 1, message: `gentle-shell: gentle-ai install timed out after ${formatTimeoutCeiling(timeoutMs)}` };
722
+ }
723
+ if (installResult.error) {
724
+ return { ok: false, exitCode: 1, message: `Could not start the gentle-ai binary: ${installResult.error.message}` };
725
+ }
726
+ if (!installResult.ok) return installResult;
727
+
728
+ return runPostInstallCleanup(home, runtime, dryRun, stdio, timeoutMs);
729
+ }
730
+
731
+ // The stderr line printed once `source` is actually removed from `home`.
732
+ // npm:gentle-pi names the running launcher's own version, so it is
733
+ // self-evident which copy stays authoritative; every other source keeps its
734
+ // original gentle-ai #4820 wording unchanged.
735
+ function postInstallRemovingMessage(source, home) {
736
+ if (source === "npm:gentle-pi") {
737
+ return `gentle-shell: removing ${source} from ${home.dir}: this launcher loads its own gentle-pi ${ownPackageVersion()}, so the home always matches it`;
738
+ }
739
+ return `gentle-shell: removing ${source} from ${home.dir}: gentle-pi ships ask_user_question and Pi refuses two providers (gentle-ai #4820)`;
740
+ }
741
+
742
+ // The --dry-run stderr line for `source`, printed unconditionally (see
743
+ // runPostInstallCleanup below). Kept byte-identical to the pre-existing
744
+ // rpiv wording; npm:gentle-pi gets its own analogous "would remove" line.
745
+ function postInstallWouldRemoveMessage(source) {
746
+ if (source === "npm:gentle-pi") {
747
+ return `gentle-shell: setup would then remove ${source} if the install declares it: this launcher loads its own gentle-pi ${ownPackageVersion()}, so the home always matches it`;
748
+ }
749
+ return `gentle-shell: setup would then remove ${source} if the install declares it (gentle-ai #4820)`;
750
+ }
751
+
752
+ // Runs once the gentle-ai install spawned by runSetupFlow above has exited
753
+ // 0. gentle-ai's managed Pi stack always declares two packages this launcher
754
+ // must remove from the just-provisioned home itself, unless this is a
755
+ // --dry-run: npm:@juicesharp/rpiv-ask-user-question, which conflicts with
756
+ // gentle-pi's own first-party ask_user_question tool (Pi refuses two
757
+ // providers for the same tool name; gentle-ai #4820, gentle-shell #1277,
758
+ // fix pending upstream), and npm:gentle-pi itself, which must never survive
759
+ // setup — this launcher always loads its own gentle-pi, never the one
760
+ // gentle-ai's stack installs. A --dry-run gentle-ai install writes nothing,
761
+ // so settings.json read afterwards would only report whatever pre-existed
762
+ // the run (e.g. the isolated-home bootstrap), never what the skipped
763
+ // install would have declared; report every known removal source
764
+ // unconditionally instead of reading settings.json at all.
765
+ async function runPostInstallCleanup(home, runtime, dryRun, stdio, timeoutMs) {
766
+ if (dryRun) {
767
+ for (const source of POST_INSTALL_REMOVAL_SOURCES) {
768
+ process.stderr.write(`${postInstallWouldRemoveMessage(source)}\n`);
769
+ }
770
+ return { ok: true, exitCode: 0 };
771
+ }
772
+ const settingsText = readJsonIfExists(join(home.dir, "settings.json"));
773
+ const removals = postInstallRemovals(settingsText);
774
+ if (removals.length === 0) return { ok: true, exitCode: 0 };
775
+ return removePostInstallSources(removals, 0, home, runtime, stdio, timeoutMs);
776
+ }
777
+
778
+ // Removes each declared post-install source in turn via the resolved pi
779
+ // runtime itself (never gentle-ai), stopping at the first failure so its
780
+ // exit code and actionable message are not masked by a later removal.
781
+ async function removePostInstallSources(sources, index, home, runtime, stdio, timeoutMs) {
782
+ if (index >= sources.length) return { ok: true, exitCode: 0 };
783
+ const source = sources[index];
784
+ process.stderr.write(`${postInstallRemovingMessage(source, home)}\n`);
785
+ const env = buildSetupEnv(home, runtime);
786
+ const result = await spawnAndWait(runtime.command, [...runtime.args, "remove", source], env, stdio, timeoutMs);
787
+ if (result.timedOut) {
788
+ return { ok: false, exitCode: 1, message: `gentle-shell: pi remove ${source} timed out after ${formatTimeoutCeiling(timeoutMs)}` };
789
+ }
790
+ if (result.error) {
791
+ return { ok: false, exitCode: 1, message: `Could not run the pi runtime to remove ${source}: ${result.error.message}` };
792
+ }
793
+ if (!result.ok) {
794
+ // Only a launcher-forwarded interrupt (R3-002) skips remediation and
795
+ // bubbles straight up; a signal death the child caused on its own is an
796
+ // ordinary failure and gets the same remediation message as any other.
797
+ if (result.interrupted) return result;
798
+ const remediation = [...homeSelectorFlags(home).map(shellQuote), "remove", source].join(" ");
799
+ return { ok: false, exitCode: result.exitCode, message: `gentle-shell: could not remove ${source}; run \`gentle-shell ${remediation}\` before starting` };
800
+ }
801
+ return removePostInstallSources(sources, index + 1, home, runtime, stdio, timeoutMs);
802
+ }
803
+
804
+ // CLI entry for `gentle-shell [home selectors] setup [--dry-run]`: parses
805
+ // --dry-run, runs the shared flow with the child's stdio inherited (today's
806
+ // behavior, unchanged), then exits with its result — this is the one place
807
+ // that keeps the pre-S7 exit semantics `handleSetupCommand` always had.
808
+ async function handleSetupCommand(commandArgs, home, runtime) {
809
+ let dryRun = false;
810
+ for (const arg of commandArgs) {
811
+ if (arg === "--dry-run") {
812
+ dryRun = true;
813
+ continue;
814
+ }
815
+ fail(`Unrecognized argument for 'gentle-shell setup': ${arg}\nRun 'gentle-shell --help' for usage.`, 2);
816
+ }
817
+
818
+ const result = await runSetupFlow(home, runtime, { dryRun, stdio: "inherit" });
819
+ if (!result.ok && result.message !== undefined) process.stderr.write(`${result.message}\n`);
820
+ process.exit(result.exitCode);
821
+ }
822
+
823
+ const AUTO_SETUP_OPT_OUT_ENV = "GENTLE_SHELL_NO_AUTO_SETUP";
824
+ const SETUP_LOCK_STALE_MS = 15 * 60 * 1000;
825
+
826
+ // gentle-shell never auto-provisions a home it does not itself own: the
827
+ // dedicated isolated home is always owned outright, but a `--home <path>` (or
828
+ // a persisted `home <path>` config) can just as easily name the user's real
829
+ // pi agent directory, or any other pre-existing, unrelated directory. Only a
830
+ // path home that is new (does not exist yet) or empty — or one this launcher
831
+ // has already provisioned before, per the config marker — is fair game;
832
+ // everything else (R1-001) is left alone with a one-time hint instead.
833
+ function defaultPiAgentDir() {
834
+ return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
835
+ }
836
+
837
+ function printForeignHomeHint(home, reason) {
838
+ const remediation = [...homeSelectorFlags(home).map(shellQuote), "setup"].join(" ");
839
+ process.stderr.write(`gentle-shell: ${home.dir} ${reason}; run \`gentle-shell ${remediation}\` to provision it\n`);
840
+ }
841
+
842
+ // Ownership marker (R3-001): written into a home's own directory by the
843
+ // isolated/--home bootstrap (main, below) the moment gentle-shell creates
844
+ // that home — the same place it seeds settings.json with tuiMode. Lets
845
+ // homeIsForeign recognize a home gentle-shell itself created even when the
846
+ // config.json provisioning marker was never written because the *first*
847
+ // auto-provision attempt against it failed (a --home directory whose
848
+ // bootstrap already seeded settings.json otherwise looks identical to an
849
+ // unrelated non-empty directory on the next launch, and would be treated as
850
+ // foreign and never retried). Content is a one-line JSON object naming the
851
+ // launcher version that created it, purely informational — homeIsForeign
852
+ // only checks the file's existence.
853
+ const HOME_OWNERSHIP_MARKER_FILENAME = ".gentle-shell-home";
854
+
855
+ function homeOwnershipMarkerPath(home) {
856
+ return join(home.dir, HOME_OWNERSHIP_MARKER_FILENAME);
857
+ }
858
+
859
+ function writeHomeOwnershipMarker(home) {
860
+ const content = JSON.stringify({ createdBy: "gentle-shell", version: ownPackageVersion() });
861
+ writeFileSync(homeOwnershipMarkerPath(home), `${content}\n`, "utf8");
862
+ }
863
+
864
+ // `homeHadContentBeforeBootstrap` must be read by the caller (main, below)
865
+ // before the isolated/--home bootstrap runs: that bootstrap itself creates
866
+ // and seeds a brand-new directory (settings.json with tuiMode, and now the
867
+ // ownership marker above), so checking directory contents from inside this
868
+ // function would always see that seeded content and wrongly call a genuinely
869
+ // fresh home "foreign".
870
+ function homeIsForeign(home, previousEntry, homeHadContentBeforeBootstrap) {
871
+ if (home.mode !== "path") return false; // the isolated home is always owned
872
+ if (previousEntry !== undefined) return false; // already provisioned by gentle-shell before; trust the marker
873
+ if (safeRealpath(home.dir) === safeRealpath(defaultPiAgentDir())) return true; // never touch pi's own default home, even if empty
874
+ if (existsSync(homeOwnershipMarkerPath(home))) return false; // gentle-shell's own bootstrap created this home (R3-001); retry it even after a failed first attempt
875
+ return homeHadContentBeforeBootstrap;
876
+ }
877
+
878
+ // Guards concurrent first-run auto-provisioning of the same home: an
879
+ // exclusive create (`wx`) fails when the lock already exists. A lock file
880
+ // younger than SETUP_LOCK_STALE_MS means another gentle-shell process is (or
881
+ // very recently was) provisioning this home, so this run skips
882
+ // auto-provisioning entirely rather than racing gentle-ai's own installer;
883
+ // the existing lock is left untouched since this run never owned it. An
884
+ // older lock is stale — a previous run crashed or was killed before its
885
+ // `finally` released it — so it is removed here, but only after re-stating
886
+ // it immediately before the removal to confirm it is *still* stale at that
887
+ // exact moment: a concurrent process may have refreshed it (or removed and
888
+ // recreated it) between the first check and now, and a lock a racing process
889
+ // just legitimately acquired must never be deleted out from under it. If the
890
+ // post-removal retry `wx` create itself then fails (another process won the
891
+ // race to recreate it first), this run simply skips provisioning rather than
892
+ // looping. Any unexpected fs error (permissions, a vanished lock between the
893
+ // EEXIST and a stat, …) must never block the launch, so it resolves to
894
+ // "proceed" rather than failing closed.
895
+ function lockAgeMs(lockPath) {
896
+ return Date.now() - statSync(lockPath).mtimeMs;
897
+ }
898
+
899
+ function acquireSetupLock(lockPath) {
900
+ try {
901
+ closeSync(openSync(lockPath, "wx"));
902
+ return true;
903
+ } catch (error) {
904
+ if (error.code !== "EEXIST") return true;
905
+ let age;
906
+ try {
907
+ age = lockAgeMs(lockPath);
908
+ } catch {
909
+ return true;
910
+ }
911
+ if (age < SETUP_LOCK_STALE_MS) {
912
+ process.stderr.write(
913
+ `gentle-shell: another gentle-shell process is already provisioning ${dirname(lockPath)}; skipping automatic setup for this run\n`,
914
+ );
915
+ return false;
916
+ }
917
+ let ageNow;
918
+ try {
919
+ ageNow = lockAgeMs(lockPath);
920
+ } catch {
921
+ return false;
922
+ }
923
+ if (ageNow < SETUP_LOCK_STALE_MS) return false;
924
+ try {
925
+ rmSync(lockPath, { force: true });
926
+ } catch {
927
+ return false;
928
+ }
929
+ try {
930
+ closeSync(openSync(lockPath, "wx"));
931
+ return true;
932
+ } catch {
933
+ return false;
934
+ }
935
+ }
936
+ }
937
+
938
+ function releaseSetupLock(lockPath) {
939
+ try {
940
+ rmSync(lockPath, { force: true });
941
+ } catch {
942
+ // Best-effort cleanup only: a missing or unremovable lock file must
943
+ // never fail an otherwise-successful (or already-failed) run.
944
+ }
945
+ }
946
+
947
+ // Ceiling for every child this flow spawns (S9): a hung pinned gentle-ai or
948
+ // pi invocation must never hang a plain `gentle-shell` launch forever.
949
+ // Test/development only: GENTLE_SHELL_AUTO_SETUP_TIMEOUT_MS overrides the
950
+ // 15-minute ceiling so a test can exercise it without actually waiting;
951
+ // documented as test/development-only in docs/readme-reference.md. Manual
952
+ // `setup` never passes a timeout at all (see handleSetupCommand).
953
+ const AUTO_SETUP_CHILD_TIMEOUT_MS = 15 * 60 * 1000;
954
+
955
+ function resolveAutoSetupTimeoutMs() {
956
+ const override = process.env.GENTLE_SHELL_AUTO_SETUP_TIMEOUT_MS;
957
+ const parsed = override !== undefined && override.length > 0 ? Number(override) : undefined;
958
+ return parsed !== undefined && Number.isFinite(parsed) ? parsed : AUTO_SETUP_CHILD_TIMEOUT_MS;
959
+ }
960
+
961
+ // Runs the same flow as `gentle-shell setup` automatically before a plain
962
+ // launch, for an isolated or `--home <path>` home that was never provisioned
963
+ // or was provisioned with a different gentle-ai pin (S7). Never runs for
964
+ // `--link` (the caller only calls this for home.mode "isolated"/"path") or a
965
+ // pi subcommand (the caller only calls this when args.piSubcommand is
966
+ // undefined) — see main() below. Never blocks the launch: a failure (an
967
+ // older pin, a missing binary the self-heal could not recover, a non-zero
968
+ // gentle-ai or pi exit, a timeout, or a spawned child dying by a signal on
969
+ // its own — a crash, an OOM kill, an external `kill`, never something this
970
+ // launcher asked for) only warns and lets the plain launch continue with
971
+ // today's injection behavior, to retry automatically on a later run —
972
+ // except an interrupt (SIGINT/SIGTERM/SIGHUP) actually reaching *this
973
+ // launcher*, which forwards it to the spawned child and returns
974
+ // `{ exitCode }` instead (R3-002) so the caller (main, below) exits the
975
+ // whole launcher immediately without starting pi (S9): the user asked this
976
+ // process to stop, not to fall back to a plain launch. Returns undefined to
977
+ // mean "continue the launch normally".
978
+ async function maybeAutoProvisionHome(home, runtime, { homeHadContentBeforeBootstrap }) {
979
+ if (process.env[AUTO_SETUP_OPT_OUT_ENV] === "1") return undefined;
980
+
981
+ const configPath = resolveConfigPath();
982
+ const homeKey = safeRealpath(home.dir);
983
+ const pin = resolveSetupGentleAiPin();
984
+ const gentlePiVersion = ownPackageVersion();
985
+ const beforeConfig = readRawConfig(configPath);
986
+ if (!needsProvisioning(beforeConfig, homeKey, pin, gentlePiVersion)) return undefined;
987
+
988
+ const previous = provisionedEntry(beforeConfig, homeKey);
989
+ if (homeIsForeign(home, previous, homeHadContentBeforeBootstrap)) {
990
+ const reason =
991
+ safeRealpath(home.dir) === safeRealpath(defaultPiAgentDir())
992
+ ? "is pi's own default agent home; gentle-shell never auto-provisions it"
993
+ : "already has content and was not set up by gentle-shell";
994
+ printForeignHomeHint(home, reason);
995
+ return undefined;
996
+ }
997
+
998
+ const lockPath = join(home.dir, ".gentle-shell-setup.lock");
999
+ if (!acquireSetupLock(lockPath)) return undefined;
1000
+
1001
+ try {
1002
+ if (previous === undefined) {
1003
+ process.stderr.write(
1004
+ `gentle-shell: first run in ${home.dir}: installing the Gentle AI companion packages (one time; set ${AUTO_SETUP_OPT_OUT_ENV}=1 to skip)\n`,
1005
+ );
1006
+ } else {
1007
+ // A marker written before gentle-pi version tracking existed (S8)
1008
+ // has no `gentlePi` field: needsProvisioning above already treats
1009
+ // that as changed, so this reports "unknown" as its prior value
1010
+ // instead of "undefined".
1011
+ const gentleAiChanged = previous.gentleAi !== pin;
1012
+ const gentlePiChanged = previous.gentlePi !== gentlePiVersion;
1013
+ if (gentleAiChanged && gentlePiChanged) {
1014
+ process.stderr.write(
1015
+ `gentle-shell: gentle-ai pin changed (${previous.gentleAi} -> ${pin}) and gentle-pi changed (${previous.gentlePi ?? "unknown"} -> ${gentlePiVersion}): updating ${home.dir}\n`,
1016
+ );
1017
+ } else if (gentlePiChanged) {
1018
+ process.stderr.write(`gentle-shell: gentle-pi changed (${previous.gentlePi ?? "unknown"} -> ${gentlePiVersion}): updating ${home.dir}\n`);
1019
+ } else {
1020
+ process.stderr.write(`gentle-shell: gentle-ai pin changed (${previous.gentleAi} -> ${pin}): updating ${home.dir}\n`);
1021
+ }
1022
+ }
1023
+
1024
+ const result = await runSetupFlow(home, runtime, { dryRun: false, stdio: ["ignore", 2, 2], timeoutMs: resolveAutoSetupTimeoutMs() });
1025
+ // Abort the whole launch only for a launcher-forwarded interrupt
1026
+ // (R3-002); a child that exited via signal on its own (crash, OOM kill,
1027
+ // external kill) falls through to the ordinary-failure branch below,
1028
+ // which warns and still starts pi.
1029
+ if (result.interrupted) return { exitCode: result.exitCode };
1030
+ if (!result.ok) {
1031
+ const remediation = [...homeSelectorFlags(home).map(shellQuote), "setup"].join(" ");
1032
+ process.stderr.write(
1033
+ `gentle-shell: automatic setup failed (exit ${result.exitCode}); starting anyway and retrying next run. Run \`gentle-shell ${remediation}\` to see the full output.\n`,
1034
+ );
1035
+ if (result.message !== undefined) process.stderr.write(`${result.message}\n`);
1036
+ return undefined;
1037
+ }
1038
+
1039
+ writeRawConfig(configPath, recordProvisioned(readRawConfig(configPath), homeKey, pin, gentlePiVersion, new Date().toISOString()));
1040
+ return undefined;
1041
+ } finally {
1042
+ releaseSetupLock(lockPath);
1043
+ }
1044
+ }
1045
+
120
1046
  async function main() {
121
1047
  const args = parseLauncherArgs(process.argv.slice(2));
122
1048
  if (args.error !== undefined) fail(`${args.error}\nRun 'gentle-shell --help' for usage.`, 2);
@@ -157,25 +1083,151 @@ async function main() {
157
1083
  process.exit(0);
158
1084
  }
159
1085
 
1086
+ // Home-ownership signal for auto-provisioning (S9, homeIsForeign): must be
1087
+ // read before the isolated-home bootstrap below creates and seeds a
1088
+ // brand-new --home directory with its own settings.json — after that
1089
+ // bootstrap runs, "did this home already have content" can no longer be
1090
+ // answered by looking at the directory.
1091
+ let homeHadContentBeforeBootstrap = false;
1092
+ if (existsSync(home.dir)) {
1093
+ try {
1094
+ homeHadContentBeforeBootstrap = readdirSync(home.dir).length > 0;
1095
+ } catch {
1096
+ homeHadContentBeforeBootstrap = false;
1097
+ }
1098
+ }
1099
+
160
1100
  // Isolated-home bootstrap: only on a home gentle-shell has not seen before
161
1101
  // (link never bootstraps — it reuses the user's own pi agent home as-is).
162
1102
  if ((home.mode === "isolated" || home.mode === "path") && !existsSync(home.dir)) {
163
1103
  mkdirSync(home.dir, { recursive: true });
164
1104
  await installIsolatedTuiModeSetting(home.dir);
1105
+ writeHomeOwnershipMarker(home); // R3-001: lets a failed first auto-provision attempt still be retried later
165
1106
  process.stderr.write(`gentle-shell: using a separate home at ${home.dir}. Run 'gentle-shell --link' to reuse your pi sign-ins and chats.\n`);
166
1107
  }
167
1108
 
168
- let linkDeclaresGentlePi = false;
169
- if (home.mode === "link") {
1109
+ if (args.command === "setup") {
1110
+ await handleSetupCommand(args.commandArgs, home, runtime);
1111
+ return;
1112
+ }
1113
+
1114
+ // Auto-provision (S7): a plain launch against an isolated or --home home
1115
+ // (never --link) runs the same flow as `gentle-shell setup` automatically
1116
+ // before pi starts, so the maintainer's own packages install without ever
1117
+ // needing to know `setup` exists. Skipped for a pi subcommand
1118
+ // (`gentle-shell install/remove/list/...`) — argv[0] must stay the bare
1119
+ // subcommand for pi to dispatch it, same reason the declaration/take-over
1120
+ // block below skips it. Must run before that block reads settings.json,
1121
+ // so that block sees settings.json exactly as this same auto-provision run
1122
+ // (if any) left it — notably with any npm:gentle-pi declaration already
1123
+ // removed again by runPostInstallCleanup, since the home never actually
1124
+ // keeps that declaration — instead of reading stale pre-setup content
1125
+ // within the same launch. Never lets an unexpected failure here (fs
1126
+ // errors, a lock, a malformed config) block the launch itself (R4): any
1127
+ // throw is caught and only warned about, exactly like an ordinary
1128
+ // setup-flow failure.
1129
+ if ((home.mode === "isolated" || home.mode === "path") && args.piSubcommand === undefined) {
1130
+ let autoProvisionResult;
1131
+ try {
1132
+ autoProvisionResult = await maybeAutoProvisionHome(home, runtime, { homeHadContentBeforeBootstrap });
1133
+ } catch (error) {
1134
+ process.stderr.write(`gentle-shell: automatic setup failed unexpectedly (${error.message}); starting anyway and retrying next run.\n`);
1135
+ autoProvisionResult = undefined;
1136
+ }
1137
+ // Only an interrupt reaching the spawned child (SIGINT/SIGTERM/SIGHUP)
1138
+ // returns a result here: the user asked this process to stop, so it
1139
+ // exits with the same signal-derived code instead of falling through
1140
+ // to launch pi.
1141
+ if (autoProvisionResult !== undefined) process.exit(autoProvisionResult.exitCode);
1142
+ }
1143
+
1144
+ const packageRootExplicit = args.packageRoot !== undefined;
1145
+ const effectivePackageRoot = packageRootExplicit ? resolvePath(args.packageRoot) : packageRoot;
1146
+ // R4-forced-package-root-unvalidated / R3-005: an unvalidated --package-root
1147
+ // forces a take-over (dropping normal extension discovery via
1148
+ // --no-extensions) and then hands pi -e/--theme/--skill/--prompt-template
1149
+ // flags pointing at directories that do not exist, turning an operator typo
1150
+ // into an obscure pi loader failure instead of a clear launcher error.
1151
+ if (packageRootExplicit && !isDirectory(effectivePackageRoot)) {
1152
+ fail(`--package-root ${args.packageRoot} does not exist or is not a directory.`, 2);
1153
+ }
1154
+
1155
+ let declaration;
1156
+ let takeOver = false;
1157
+ let otherPackagePaths = [];
1158
+ let looseExtensionEntries = [];
1159
+
1160
+ // Every mode consults the home's own settings.json for a gentle-pi
1161
+ // declaration, not just --link: `gentle-shell setup` installs
1162
+ // npm:gentle-pi into an isolated or --home home's settings.json, and once
1163
+ // that declaration exists the launcher must stop injecting its own copy
1164
+ // on top of it (buildPiInvocation skips injection whenever a declaration
1165
+ // is present and there is no take-over). A path declaration in a
1166
+ // non-link home follows the same take-over rules as --link. A home
1167
+ // without any declaration keeps the plain injection, unchanged.
1168
+ //
1169
+ // --package-root only forces a take-over in --link mode: an isolated or
1170
+ // --home target has no pre-existing pi installation to defer to, so
1171
+ // forcing --no-extensions there would just strip its own settings-driven
1172
+ // discovery for no benefit (see the "gated on link mode" bin test). A pi
1173
+ // subcommand skips this whole block: buildPiInvocation ignores
1174
+ // takeOver/declaration once piSubcommand is set, and running the
1175
+ // take-over/loose-dir discovery anyway would still print a misleading
1176
+ // "taking over gentle-pi..." message (and otherPackageInjections
1177
+ // warnings) for a plain `gentle-shell install npm:x` that never actually
1178
+ // takes anything over.
1179
+ if (args.piSubcommand === undefined) {
170
1180
  const settingsText = readJsonIfExists(join(home.dir, "settings.json"));
171
- linkDeclaresGentlePi = settingsDeclareGentlePi(settingsText);
1181
+ declaration = findGentlePiDeclaration(settingsText, { agentDir: home.dir, readPackageName });
1182
+ // --package-root only forces a take-over in --link mode (see below);
1183
+ // in every other mode a declared home silently keeps using its
1184
+ // declared gentle-pi and --package-root has no effect at all. Warn
1185
+ // once so an operator does not assume --package-root took effect.
1186
+ if (packageRootExplicit && home.mode !== "link" && declaration !== undefined) {
1187
+ process.stderr.write(
1188
+ `gentle-shell: --package-root only forces a take-over in --link mode; ${home.dir} declares gentle-pi, so the installed package is used and ${args.packageRoot} is ignored\n`,
1189
+ );
1190
+ }
1191
+ const realEffectivePackageRoot = safeRealpath(effectivePackageRoot);
1192
+ const realDeclaredDir = declaration?.kind === "path" ? safeRealpath(declaration.dir) : undefined;
1193
+ takeOver = decideTakeOver({
1194
+ declaration,
1195
+ realPackageRoot: realEffectivePackageRoot,
1196
+ realDeclaredDir,
1197
+ packageRootExplicit: home.mode === "link" && packageRootExplicit,
1198
+ });
1199
+ if (takeOver) {
1200
+ const skip = declaration ?? { kind: "path", dir: realEffectivePackageRoot };
1201
+ const injections = otherPackageInjections({ settingsText, agentDir: home.dir, skip, isDirectory, realpath: safeRealpath });
1202
+ otherPackagePaths = injections.paths;
1203
+ for (const warning of injections.warnings) process.stderr.write(`${warning}\n`);
1204
+ // --no-extensions drops pi's normal settings-driven extension
1205
+ // discovery, which also covers loose (non-package) extensions
1206
+ // under <agentDir>/extensions and the project-local
1207
+ // <cwd>/.pi/extensions. Re-injecting either directory wholesale
1208
+ // as `-e <dir>` does not work for a directory of loose files: pi's
1209
+ // -e flag hands the path straight to its module loader with no
1210
+ // directory-discovery pass, so a bare directory of loose files
1211
+ // fails with "Cannot find module ...". Resolve each candidate
1212
+ // into its actual loose file entries (or pass it through
1213
+ // unchanged when it is itself a self-contained extension) so a
1214
+ // take-over does not silently stop loading them.
1215
+ looseExtensionEntries = [join(home.dir, "extensions"), join(process.cwd(), ".pi", "extensions")].flatMap(resolveLooseExtensionEntries);
1216
+ const declaredFrom = declaration === undefined ? "the requested package root" : declaration.kind === "npm" ? "npm:gentle-pi" : declaration.dir;
1217
+ process.stderr.write(
1218
+ `gentle-shell: taking over gentle-pi from ${declaredFrom} for this run (settings unchanged; its skills, prompts, and themes still load alongside this launcher's).\n`,
1219
+ );
1220
+ }
172
1221
  }
173
1222
 
174
1223
  const invocation = buildPiInvocation({
175
1224
  runtime,
176
1225
  home,
177
- packageRoot,
178
- settingsDeclareGentlePi: home.mode === "link" ? linkDeclaresGentlePi : false,
1226
+ packageRoot: effectivePackageRoot,
1227
+ declaration,
1228
+ takeOver,
1229
+ otherPackagePaths,
1230
+ looseExtensionEntries,
179
1231
  passthrough: args.passthrough,
180
1232
  piSubcommand: args.piSubcommand,
181
1233
  baseEnv: process.env,