Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c191b1296 | ||
|
|
17e95754b1 |
Generated
+1
-1
@@ -7933,7 +7933,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "3.7.0",
|
"version": "3.7.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.0",
|
"@fastify/cookie": "^11.0.0",
|
||||||
|
|||||||
@@ -4,6 +4,22 @@ Notable changes to the Arboretum desktop app (`packages/desktop`). The daemon an
|
|||||||
extension keep their own changelogs in `packages/server/CHANGELOG.md` and
|
extension keep their own changelogs in `packages/server/CHANGELOG.md` and
|
||||||
`packages/vscode/CHANGELOG.md`.
|
`packages/vscode/CHANGELOG.md`.
|
||||||
|
|
||||||
|
## 0.2.5
|
||||||
|
|
||||||
|
Ships the daemon 3.7.1, and finishes the job started in 0.2.4: an update installed while the app runs
|
||||||
|
now applies **itself**.
|
||||||
|
|
||||||
|
- **The restart after an update no longer needs you.** 0.2.4 detected that the binary had been
|
||||||
|
replaced and offered a *Restart now* dialog. That still made the user do the work. The app now
|
||||||
|
restarts on its own when it costs nothing, which is the common case, and only asks when there is
|
||||||
|
something to lose: the dialog appears when live sessions would be interrupted (it says how many),
|
||||||
|
or when the daemon cannot be reached to find out. A previous *Later* is final for that version, so
|
||||||
|
nothing ever restarts behind your back.
|
||||||
|
- **The update is now noticed while the window is open.** Detection used to run only when the window
|
||||||
|
was re-shown (tray, Dock, second launch), so an update installed during a working session could go
|
||||||
|
unnoticed indefinitely. A cheap `stat` every 30 s covers it, by polling rather than `fs.watch`,
|
||||||
|
because a package replacing the binary or a whole directory often produces no watch event at all.
|
||||||
|
|
||||||
## 0.2.4
|
## 0.2.4
|
||||||
|
|
||||||
Ships the daemon 3.7.0: terminals no longer go black, they can sit side by side in resizable columns,
|
Ships the daemon 3.7.0: terminals no longer go black, they can sit side by side in resizable columns,
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@arboretum/desktop",
|
"name": "@arboretum/desktop",
|
||||||
"version": "0.2.4",
|
"version": "0.2.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@arboretum/desktop",
|
"name": "@arboretum/desktop",
|
||||||
"version": "0.2.4",
|
"version": "0.2.5",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@arboretum/desktop",
|
"name": "@arboretum/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.4",
|
"version": "0.2.5",
|
||||||
"description": "Self-hosted multi-project AI IDE for git worktrees and Claude Code sessions",
|
"description": "Self-hosted multi-project AI IDE for git worktrees and Claude Code sessions",
|
||||||
"homepage": "https://git-arboretum.com",
|
"homepage": "https://git-arboretum.com",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { registerClipboardBridge } from './clipboard';
|
|||||||
import { initUpdater } from './updater';
|
import { initUpdater } from './updater';
|
||||||
import { resolveIconPath } from './paths';
|
import { resolveIconPath } from './paths';
|
||||||
import { describeStartFailure } from './start-failure';
|
import { describeStartFailure } from './start-failure';
|
||||||
import { installChanged, readInstallStamp, type InstallStamp } from './upgrade-watch';
|
import { decideUpgradeAction, installChanged, pollInstall, readInstallStamp, type InstallStamp } from './upgrade-watch';
|
||||||
|
|
||||||
// WM_CLASS / app_id déterministe, posé AVANT app.whenReady(). Sous Wayland (défaut Debian/GNOME)
|
// WM_CLASS / app_id déterministe, posé AVANT app.whenReady(). Sous Wayland (défaut Debian/GNOME)
|
||||||
// l'option `icon:` de BrowserWindow est ignorée : l'icône de fenêtre/dock vient du fichier .desktop
|
// l'option `icon:` de BrowserWindow est ignorée : l'icône de fenêtre/dock vient du fichier .desktop
|
||||||
@@ -44,6 +44,11 @@ let serverReady = false;
|
|||||||
const bootStamp = readInstallStamp(process.execPath);
|
const bootStamp = readInstallStamp(process.execPath);
|
||||||
let dismissedStamp: InstallStamp | null = null;
|
let dismissedStamp: InstallStamp | null = null;
|
||||||
let restartPromptOpen = false;
|
let restartPromptOpen = false;
|
||||||
|
/** Surveillance de l'installation : une mise à jour posée à chaud doit se voir sans que l'utilisateur
|
||||||
|
* ait à toucher à quoi que ce soit, fenêtre ouverte comprise. */
|
||||||
|
let installPoll: { stop: () => void } | null = null;
|
||||||
|
/** Intervalle du poll : un `stat` toutes les 30 s est indolore et suffit largement. */
|
||||||
|
const INSTALL_POLL_MS = 30_000;
|
||||||
|
|
||||||
// Instance unique : deux instances = deux daemons/ports en conflit.
|
// Instance unique : deux instances = deux daemons/ports en conflit.
|
||||||
if (!app.requestSingleInstanceLock()) {
|
if (!app.requestSingleInstanceLock()) {
|
||||||
@@ -103,6 +108,16 @@ async function bootstrap(): Promise<void> {
|
|||||||
installAppMenu({ url: daemonUrl(), onQuit: quitApp });
|
installAppMenu({ url: daemonUrl(), onQuit: quitApp });
|
||||||
if (!tray) tray = createTray({ show: showWindow, quit: quitApp });
|
if (!tray) tray = createTray({ show: showWindow, quit: quitApp });
|
||||||
initUpdater();
|
initUpdater();
|
||||||
|
// Détection CONTINUE : sans elle, une mise à jour installée pendant que la fenêtre est ouverte
|
||||||
|
// n'était remarquée qu'au prochain passage par le tray ou le Dock, donc parfois jamais.
|
||||||
|
if (app.isPackaged && !installPoll) {
|
||||||
|
installPoll = pollInstall({
|
||||||
|
path: process.execPath,
|
||||||
|
intervalMs: INSTALL_POLL_MS,
|
||||||
|
boot: bootStamp,
|
||||||
|
onChanged: () => void handleUpgradeInstalled(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Daemon + cookie de session : le strict nécessaire pour charger la SPA (aussi utilisé au redémarrage). */
|
/** Daemon + cookie de session : le strict nécessaire pour charger la SPA (aussi utilisé au redémarrage). */
|
||||||
@@ -205,33 +220,71 @@ app.on('activate', showWindow);
|
|||||||
|
|
||||||
function showWindow(): void {
|
function showWindow(): void {
|
||||||
// Tray, second-instance et Dock passent tous ici : c'est le moment où l'utilisateur redemande
|
// Tray, second-instance et Dock passent tous ici : c'est le moment où l'utilisateur redemande
|
||||||
// l'app, donc le bon moment pour signaler une mise à jour installée entre-temps.
|
// l'app, donc le bon moment pour traiter une mise à jour installée entre-temps.
|
||||||
void maybePromptRestartAfterUpgrade();
|
void handleUpgradeInstalled();
|
||||||
if (!win) return;
|
if (!win) return;
|
||||||
if (win.isMinimized()) win.restore();
|
if (win.isMinimized()) win.restore();
|
||||||
win.show();
|
win.show();
|
||||||
win.focus();
|
win.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nombre de sessions VIVANTES hébergées par le daemon : ce sont les seules choses qu'un redémarrage
|
||||||
|
* détruit. `null` quand on n'a pas pu savoir (daemon injoignable) : l'appelant demandera alors.
|
||||||
|
* Pas d'en-tête Origin sur ce fetch, donc le check Origin strict du serveur ne s'y applique pas.
|
||||||
|
*/
|
||||||
|
async function countLiveSessions(): Promise<number | null> {
|
||||||
|
const handle = daemon;
|
||||||
|
if (!handle || !serverReady) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${handle.url}/api/v1/sessions`, { headers: { Authorization: `Bearer ${handle.token}` } });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const body = (await res.json()) as { sessions?: Array<{ live?: boolean }> };
|
||||||
|
return (body.sessions ?? []).filter((s) => s.live).length;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mise à jour installée pendant que l'app tournait : le lock d'instance unique renvoie les lancements
|
* Mise à jour installée pendant que l'app tournait : le lock d'instance unique renvoie les lancements
|
||||||
* suivants sur la fenêtre de l'ANCIENNE version, sans un mot, et l'utilisateur croit avoir migré.
|
* suivants sur la fenêtre de l'ANCIENNE version, sans un mot, et l'utilisateur croit avoir migré.
|
||||||
|
*
|
||||||
|
* Objectif : zéro manipulation. Quand un redémarrage ne coûte RIEN (aucune session vivante), on
|
||||||
|
* redémarre tout seul. On ne demande que s'il y a du travail en cours à interrompre, ou si le daemon
|
||||||
|
* ne répond pas. Un « Later » précédent est définitif pour cette version.
|
||||||
*/
|
*/
|
||||||
async function maybePromptRestartAfterUpgrade(): Promise<void> {
|
async function handleUpgradeInstalled(): Promise<void> {
|
||||||
if (restartPromptOpen || isQuitting || !app.isPackaged) return;
|
if (restartPromptOpen || isQuitting || shuttingDown || !app.isPackaged) return;
|
||||||
const current = readInstallStamp(process.execPath);
|
const current = readInstallStamp(process.execPath);
|
||||||
if (!installChanged(bootStamp, current)) return;
|
if (!current || !installChanged(bootStamp, current)) return;
|
||||||
if (dismissedStamp && !installChanged(dismissedStamp, current)) return;
|
|
||||||
|
const dismissed = !!dismissedStamp && !installChanged(dismissedStamp, current);
|
||||||
|
const liveSessions = dismissed ? 0 : await countLiveSessions();
|
||||||
|
const action = decideUpgradeAction({ changed: true, liveSessions, dismissed });
|
||||||
|
if (action === 'none') return;
|
||||||
|
|
||||||
|
if (action === 'restart') {
|
||||||
|
// Rien à perdre : on applique la mise à jour sans rien demander. C'est le cas courant.
|
||||||
|
installPoll?.stop();
|
||||||
|
installPoll = null;
|
||||||
|
relaunchAfterQuit = true;
|
||||||
|
quitApp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
restartPromptOpen = true;
|
restartPromptOpen = true;
|
||||||
try {
|
try {
|
||||||
|
const running = liveSessions ?? 0;
|
||||||
const { response } = await dialog.showMessageBox({
|
const { response } = await dialog.showMessageBox({
|
||||||
type: 'info',
|
type: 'info',
|
||||||
title: 'Arboretum',
|
title: 'Arboretum',
|
||||||
message: 'A new version of Arboretum has been installed',
|
message: 'A new version of Arboretum has been installed',
|
||||||
detail:
|
detail:
|
||||||
`This window still runs version ${app.getVersion()}, started before the update. ` +
|
`This window still runs version ${app.getVersion()}, started before the update. ` +
|
||||||
'Restart to load the installed version. Running sessions will be stopped.',
|
(running > 0
|
||||||
|
? `Restarting stops ${running} running session${running > 1 ? 's' : ''}. They can be resumed afterwards.`
|
||||||
|
: 'Restart to load the installed version.'),
|
||||||
buttons: ['Restart now', 'Later'],
|
buttons: ['Restart now', 'Later'],
|
||||||
defaultId: 0,
|
defaultId: 0,
|
||||||
cancelId: 1,
|
cancelId: 1,
|
||||||
|
|||||||
@@ -26,3 +26,46 @@ export function installChanged(boot: InstallStamp | null, current: InstallStamp
|
|||||||
if (!boot || !current) return false;
|
if (!boot || !current) return false;
|
||||||
return boot.ino !== current.ino || boot.mtimeMs !== current.mtimeMs || boot.size !== current.size;
|
return boot.ino !== current.ino || boot.mtimeMs !== current.mtimeMs || boot.size !== current.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ce qu'il faut faire d'une mise à jour installée à chaud.
|
||||||
|
*
|
||||||
|
* Le but est de ne PAS faire porter la manipulation à l'utilisateur : quand redémarrer ne coûte rien,
|
||||||
|
* on redémarre. La seule chose qu'un redémarrage détruit, ce sont les sessions vivantes hébergées par
|
||||||
|
* le daemon (des agents en train de travailler) : là seulement, on demande.
|
||||||
|
*
|
||||||
|
* `liveSessions === null` = on n'a pas pu le savoir (daemon injoignable) : on demande, par prudence.
|
||||||
|
* Un refus précédent (« Later ») est respecté, y compris si les sessions se terminent ensuite : on ne
|
||||||
|
* redémarre jamais dans le dos de quelqu'un qui a dit non.
|
||||||
|
*/
|
||||||
|
export function decideUpgradeAction(input: {
|
||||||
|
changed: boolean;
|
||||||
|
liveSessions: number | null;
|
||||||
|
dismissed: boolean;
|
||||||
|
}): 'none' | 'restart' | 'ask' {
|
||||||
|
if (!input.changed || input.dismissed) return 'none';
|
||||||
|
return input.liveSessions === 0 ? 'restart' : 'ask';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surveillance de l'installation par POLL, et non par `fs.watch` : un paquet remplace le binaire
|
||||||
|
* (nouvel inode) ou tout un répertoire, et selon le gestionnaire de paquets et le système de
|
||||||
|
* fichiers, `fs.watch` sur le fichier ne voit alors plus rien. Un `stat` périodique est trivial en
|
||||||
|
* coût et se comporte pareil partout. Le premier changement suffit : on arrête de surveiller.
|
||||||
|
*/
|
||||||
|
export function pollInstall(opts: {
|
||||||
|
path: string;
|
||||||
|
intervalMs: number;
|
||||||
|
boot: InstallStamp | null;
|
||||||
|
onChanged: (current: InstallStamp) => void;
|
||||||
|
}): { stop: () => void } {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const current = readInstallStamp(opts.path);
|
||||||
|
if (!current || !installChanged(opts.boot, current)) return;
|
||||||
|
clearInterval(timer);
|
||||||
|
opts.onChanged(current);
|
||||||
|
}, opts.intervalMs);
|
||||||
|
// Ne retient pas la boucle d'événements : ce timer ne doit jamais empêcher l'app de quitter.
|
||||||
|
timer.unref?.();
|
||||||
|
return { stop: () => clearInterval(timer) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { mkdtempSync, utimesSync, writeFileSync } from 'node:fs';
|
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { installChanged, readInstallStamp } from '../src/main/upgrade-watch';
|
import { decideUpgradeAction, installChanged, pollInstall, readInstallStamp } from '../src/main/upgrade-watch';
|
||||||
|
|
||||||
describe('upgrade-watch', () => {
|
describe('upgrade-watch', () => {
|
||||||
it('lit une empreinte de fichier, et rien pour un chemin absent', () => {
|
it('lit une empreinte de fichier, et rien pour un chemin absent', () => {
|
||||||
@@ -31,3 +31,70 @@ describe('upgrade-watch', () => {
|
|||||||
expect(installChanged(null, null)).toBe(false);
|
expect(installChanged(null, null)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Objectif produit : ZÉRO manipulation quand c'est sans risque. Un redémarrage ne détruit qu'une
|
||||||
|
// chose, les sessions vivantes hébergées par le daemon : elles seules justifient de demander.
|
||||||
|
describe('decideUpgradeAction', () => {
|
||||||
|
it('rien à faire si l’installation n’a pas changé', () => {
|
||||||
|
expect(decideUpgradeAction({ changed: false, liveSessions: 0, dismissed: false })).toBe('none');
|
||||||
|
expect(decideUpgradeAction({ changed: false, liveSessions: 3, dismissed: false })).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aucune session vivante : redémarrage automatique, sans dialogue', () => {
|
||||||
|
expect(decideUpgradeAction({ changed: true, liveSessions: 0, dismissed: false })).toBe('restart');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('des sessions tournent : on demande avant de les interrompre', () => {
|
||||||
|
expect(decideUpgradeAction({ changed: true, liveSessions: 1, dismissed: false })).toBe('ask');
|
||||||
|
expect(decideUpgradeAction({ changed: true, liveSessions: 9, dismissed: false })).toBe('ask');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('état inconnu (daemon injoignable) : on demande, par prudence', () => {
|
||||||
|
expect(decideUpgradeAction({ changed: true, liveSessions: null, dismissed: false })).toBe('ask');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un « Later » est définitif : jamais de redémarrage dans le dos de l’utilisateur', () => {
|
||||||
|
expect(decideUpgradeAction({ changed: true, liveSessions: 0, dismissed: true })).toBe('none');
|
||||||
|
expect(decideUpgradeAction({ changed: true, liveSessions: null, dismissed: true })).toBe('none');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pollInstall', () => {
|
||||||
|
it('signale le premier changement, puis s’arrête de lui-même', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'arb-poll-'));
|
||||||
|
const file = join(dir, 'app');
|
||||||
|
try {
|
||||||
|
writeFileSync(file, 'v1');
|
||||||
|
const boot = readInstallStamp(file);
|
||||||
|
const seen: number[] = [];
|
||||||
|
const handle = pollInstall({ path: file, intervalMs: 10, boot, onChanged: (c) => seen.push(c.size) });
|
||||||
|
await new Promise((r) => setTimeout(r, 40));
|
||||||
|
expect(seen).toHaveLength(0); // rien n'a bougé
|
||||||
|
|
||||||
|
writeFileSync(file, 'v2-plus-long');
|
||||||
|
await new Promise((r) => setTimeout(r, 80));
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
|
||||||
|
// un second changement ne doit PAS rappeler : le poll s'arrête au premier
|
||||||
|
writeFileSync(file, 'v3-encore-plus-long');
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
handle.stop();
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un chemin illisible ne déclenche rien', async () => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const handle = pollInstall({
|
||||||
|
path: '/definitely/not/here',
|
||||||
|
intervalMs: 10,
|
||||||
|
boot: { ino: 1, mtimeMs: 1, size: 1 },
|
||||||
|
onChanged: () => seen.push('x'),
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
expect(seen).toHaveLength(0);
|
||||||
|
handle.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,20 @@
|
|||||||
Notable changes to `@johanleroy/git-arboretum` (the Arboretum daemon). The VS Code
|
Notable changes to `@johanleroy/git-arboretum` (the Arboretum daemon). The VS Code
|
||||||
extension keeps its own changelog in `packages/vscode/CHANGELOG.md`.
|
extension keeps its own changelog in `packages/vscode/CHANGELOG.md`.
|
||||||
|
|
||||||
|
## 3.7.1
|
||||||
|
|
||||||
|
- **Sessions launched from Arboretum lost their transcript.** When the daemon itself was started from a
|
||||||
|
Claude Code session (an agent launching the desktop app, or `arboretum` started from a Claude
|
||||||
|
terminal), it inherited that session's runtime markers and passed them on to every session it
|
||||||
|
spawned. `CLAUDE_CODE_CHILD_SESSION=1` makes the CLI believe it is a sub-session, so it turns
|
||||||
|
transcript saving off: no history, no `--resume`, `claudeSessionId` stays null, and with it the fine
|
||||||
|
busy/waiting/idle state. The visible symptom was a warning in the terminal: *Transcript saving is
|
||||||
|
off, inherited CLAUDE_CODE_CHILD_SESSION marker*. The PTY environment is now stripped of those
|
||||||
|
markers (`CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION`, `CLAUDE_CODE_SESSION_ID`,
|
||||||
|
`CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_CODE_EXECPATH`, `CLAUDE_PID`, `CLAUDE_EFFORT`) for `claude` **and**
|
||||||
|
for shells, since a `claude` typed by hand in a terminal would inherit them too. Legitimate user
|
||||||
|
configuration (`CLAUDE_CONFIG_DIR`, `ANTHROPIC_*`, proxies) is untouched.
|
||||||
|
|
||||||
## 3.7.0
|
## 3.7.0
|
||||||
|
|
||||||
Terminals stop going black, and they now sit side by side. Files and commits follow the terminal you
|
Terminals stop going black, and they now sit side by side. Files and commits follow the terminal you
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@johanleroy/git-arboretum",
|
"name": "@johanleroy/git-arboretum",
|
||||||
"version": "3.7.0",
|
"version": "3.7.1",
|
||||||
"description": "Self-hosted multi-project AI IDE for git worktrees and the Claude Code sessions running on them",
|
"description": "Self-hosted multi-project AI IDE for git worktrees and the Claude Code sessions running on them",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -29,8 +29,20 @@ const check = (name, ok, detail = '') => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tmp = mkdtempSync(join(tmpdir(), 'arb-p17-'));
|
const tmp = mkdtempSync(join(tmpdir(), 'arb-p17-'));
|
||||||
|
// Daemon lancé avec un environnement POLLUÉ, exactement comme lorsqu'il est démarré depuis une
|
||||||
|
// session Claude Code (cas vécu : l'app de bureau lancée par un agent). Ces marqueurs ne doivent
|
||||||
|
// JAMAIS atteindre les sessions qu'il lance, sinon le CLI se croit sous-session et coupe la
|
||||||
|
// sauvegarde de son transcript (plus d'historique, plus de --resume).
|
||||||
const srv = spawn('node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--no-discover'], {
|
const srv = spawn('node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--no-discover'], {
|
||||||
env: { ...process.env, ARBORETUM_LOG: 'warn' },
|
env: {
|
||||||
|
...process.env,
|
||||||
|
ARBORETUM_LOG: 'warn',
|
||||||
|
CLAUDECODE: '1',
|
||||||
|
CLAUDE_CODE_CHILD_SESSION: '1',
|
||||||
|
CLAUDE_CODE_SESSION_ID: 'parent-session-id',
|
||||||
|
CLAUDE_PID: '424242',
|
||||||
|
ARB_MARQUEUR_LEGITIME: 'conserve-moi',
|
||||||
|
},
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
let srvOut = '';
|
let srvOut = '';
|
||||||
@@ -181,7 +193,22 @@ try {
|
|||||||
check('épilogue : le code de sortie est écrit dans le terminal', epilogue.includes('[arboretum]') && epilogue.includes('exited with code 3'), epilogue.slice(-60).replace(/[\r\n]+/g, ' '));
|
check('épilogue : le code de sortie est écrit dans le terminal', epilogue.includes('[arboretum]') && epilogue.includes('exited with code 3'), epilogue.slice(-60).replace(/[\r\n]+/g, ' '));
|
||||||
check('épilogue reçu AVANT le detached', !!detached5);
|
check('épilogue reçu AVANT le detached', !!detached5);
|
||||||
|
|
||||||
// --- 6. Le PTY n'a pas été redimensionné par les attaches sans écran ---
|
// --- 6. L'environnement du PTY est assaini des marqueurs de la session parente ---
|
||||||
|
// Le nom du marqueur est CONCATÉNÉ dans la commande ('EN' + 'V:') pour que l'écho local du terminal
|
||||||
|
// ne ressemble pas au résultat : sinon on relit sa propre frappe et le test passe toujours.
|
||||||
|
c2.send({ type: 'stdin', channel: att2.channel, data: 'echo "EN""V:[$CLAUDECODE][$CLAUDE_CODE_CHILD_SESSION][$CLAUDE_CODE_SESSION_ID][$CLAUDE_PID]"\r' });
|
||||||
|
await sleep(900);
|
||||||
|
const envLine = /ENV:\[[^\]]*\]\[[^\]]*\]\[[^\]]*\]\[[^\]]*\]/.exec(c2.outputOf(att2.channel).replace(/\r?\n/g, ''))?.[0] ?? '';
|
||||||
|
check('les marqueurs de session parente ne sont pas transmis au PTY', envLine === 'ENV:[][][][]', envLine || 'non observé');
|
||||||
|
|
||||||
|
c2.send({ type: 'stdin', channel: att2.channel, data: 'echo "GARDE:[$ARB_MARQUEUR_LEGITIME]"\r' });
|
||||||
|
await sleep(900);
|
||||||
|
check(
|
||||||
|
'le reste de l’environnement est bien transmis',
|
||||||
|
c2.outputOf(att2.channel).includes('GARDE:[conserve-moi]'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- 7. Le PTY n'a pas été redimensionné par les attaches sans écran ---
|
||||||
const listed = await (await api('/api/v1/sessions')).json();
|
const listed = await (await api('/api/v1/sessions')).json();
|
||||||
check('session toujours vivante après tout ça', listed.sessions.some((s) => s.id === sid && s.live));
|
check('session toujours vivante après tout ça', listed.sessions.some((s) => s.id === sid && s.live));
|
||||||
|
|
||||||
|
|||||||
@@ -146,11 +146,43 @@ export function resolvePlainShell(platform: NodeJS.Platform = process.platform):
|
|||||||
return { file: 'bash', args: ['--norc'] };
|
return { file: 'bash', args: ['--norc'] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marqueurs d'EXÉCUTION que le CLI claude pose dans l'environnement de ses processus enfants. Si le
|
||||||
|
* daemon a lui-même été lancé depuis une session Claude Code (ce qui arrive : `arboretum` démarré
|
||||||
|
* depuis un terminal Claude, ou l'app de bureau lancée par un agent), il les hérite et les
|
||||||
|
* retransmettait à CHAQUE session qu'il lance. Conséquences observées :
|
||||||
|
* - `CLAUDE_CODE_CHILD_SESSION=1` fait croire au CLI qu'il est une sous-session : il DÉSACTIVE la
|
||||||
|
* sauvegarde du transcript (« Transcript saving is off »), donc plus d'historique, plus de
|
||||||
|
* `--resume`, et `claudeSessionId` reste null (l'état fin busy/waiting/idle tombe avec lui) ;
|
||||||
|
* - `CLAUDE_CODE_SESSION_ID` / `CLAUDE_PID` désignent la session PARENTE, pas celle qu'on lance.
|
||||||
|
* On ne retire QUE ces marqueurs : la configuration légitime de l'utilisateur (`CLAUDE_CONFIG_DIR`,
|
||||||
|
* `ANTHROPIC_*`, proxies...) doit passer telle quelle, sinon on casserait son installation.
|
||||||
|
*/
|
||||||
|
export const INHERITED_CLAUDE_MARKERS = [
|
||||||
|
'CLAUDECODE',
|
||||||
|
'CLAUDE_CODE_CHILD_SESSION',
|
||||||
|
'CLAUDE_CODE_SESSION_ID',
|
||||||
|
'CLAUDE_CODE_ENTRYPOINT',
|
||||||
|
'CLAUDE_CODE_EXECPATH',
|
||||||
|
'CLAUDE_PID',
|
||||||
|
'CLAUDE_EFFORT',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environnement assaini pour un PTY : pur et testable. Appliqué aussi au shell (`bash`), car un
|
||||||
|
* `claude` lancé à la main dans ce terminal hériterait des mêmes marqueurs.
|
||||||
|
*/
|
||||||
|
export function sanitizeInheritedEnv(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||||
|
const env: NodeJS.ProcessEnv = { ...source };
|
||||||
|
for (const key of INHERITED_CLAUDE_MARKERS) delete env[key];
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
/** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */
|
/** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */
|
||||||
export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
|
||||||
const platform = opts.platform ?? process.platform;
|
const platform = opts.platform ?? process.platform;
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...sanitizeInheritedEnv(process.env),
|
||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
COLORTERM: 'truecolor',
|
COLORTERM: 'truecolor',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { buildSpawnSpec, diagnoseClaudeBin, resolveClaudeBin } from '../src/core/claude-launcher.js';
|
import { buildSpawnSpec, diagnoseClaudeBin, INHERITED_CLAUDE_MARKERS, resolveClaudeBin, sanitizeInheritedEnv } from '../src/core/claude-launcher.js';
|
||||||
|
|
||||||
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
|
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
|
||||||
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
|
||||||
@@ -52,3 +52,56 @@ describe('resolveClaudeBin / diagnoseClaudeBin · override de chemin (réglage U
|
|||||||
expect(diagnoseClaudeBin()).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true });
|
expect(diagnoseClaudeBin()).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Régression vécue : l'app de bureau avait été lancée depuis une session Claude Code, donc le daemon
|
||||||
|
// héritait de `CLAUDE_CODE_CHILD_SESSION=1` et le repassait à CHAQUE session lancée. Le CLI se croyait
|
||||||
|
// sous-session et coupait la sauvegarde du transcript : « Transcript saving is off », plus aucun
|
||||||
|
// historique, plus de `--resume`, et `claudeSessionId` restait null (l'état fin tombe avec lui).
|
||||||
|
describe('sanitizeInheritedEnv · marqueurs de session parente', () => {
|
||||||
|
const polluted = {
|
||||||
|
PATH: '/usr/bin',
|
||||||
|
CLAUDECODE: '1',
|
||||||
|
CLAUDE_CODE_CHILD_SESSION: '1',
|
||||||
|
CLAUDE_CODE_SESSION_ID: 'afad72f8-a987-462a-9406-7fd144e05905',
|
||||||
|
CLAUDE_CODE_ENTRYPOINT: 'cli',
|
||||||
|
CLAUDE_CODE_EXECPATH: '/home/u/.local/share/claude/versions/2.1.222',
|
||||||
|
CLAUDE_PID: '1813704',
|
||||||
|
CLAUDE_EFFORT: 'xhigh',
|
||||||
|
CLAUDE_CONFIG_DIR: '/home/u/.claude',
|
||||||
|
ANTHROPIC_API_KEY: 'sk-test',
|
||||||
|
HTTPS_PROXY: 'http://proxy:3128',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('retire les marqueurs d’exécution hérités', () => {
|
||||||
|
const env = sanitizeInheritedEnv(polluted);
|
||||||
|
for (const key of INHERITED_CLAUDE_MARKERS) expect(env[key]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('conserve la configuration légitime de l’utilisateur', () => {
|
||||||
|
const env = sanitizeInheritedEnv(polluted);
|
||||||
|
expect(env.CLAUDE_CONFIG_DIR).toBe('/home/u/.claude');
|
||||||
|
expect(env.ANTHROPIC_API_KEY).toBe('sk-test');
|
||||||
|
expect(env.HTTPS_PROXY).toBe('http://proxy:3128');
|
||||||
|
expect(env.PATH).toBe('/usr/bin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne mute pas la source', () => {
|
||||||
|
const copy = { ...polluted };
|
||||||
|
sanitizeInheritedEnv(copy);
|
||||||
|
expect(copy.CLAUDE_CODE_CHILD_SESSION).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buildSpawnSpec assainit l’env, pour claude ET pour le shell', () => {
|
||||||
|
const previous = process.env.CLAUDE_CODE_CHILD_SESSION;
|
||||||
|
process.env.CLAUDE_CODE_CHILD_SESSION = '1';
|
||||||
|
try {
|
||||||
|
expect(buildSpawnSpec({ command: 'claude' }).env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined();
|
||||||
|
// un `claude` tapé à la main dans ce shell hériterait sinon du même marqueur
|
||||||
|
expect(buildSpawnSpec({ command: 'bash' }).env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined();
|
||||||
|
expect(buildSpawnSpec({ command: 'claude' }).env.TERM).toBe('xterm-256color');
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env.CLAUDE_CODE_CHILD_SESSION;
|
||||||
|
else process.env.CLAUDE_CODE_CHILD_SESSION = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user