Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ bash examples/setup-demo.sh ~/loopkit-demo/notes
mkdir -p ~/loopkit-demo/plane && cd ~/loopkit-demo/plane && git init -b main
mkdir -p .ai/loops/prompts
cp "$LOOPKIT_REPO_ROOT"/packages/core/prompts/*.md .ai/loops/prompts/
echo 'export LOOPKIT_AUTONOMY=off' > .ai/loops/config.env # fail-safe while you inspect it
echo 'LOOPKIT_AUTONOMY=off' > .ai/loops/config.env # fail-safe while you inspect it
source "$LOOPKIT_REPO_ROOT/scripts/load-plane-env.sh"

# 3. Connect the target (prints its manifest — gate command, branch — for your review)
$LOOPCTL target add ~/loopkit-demo/notes
Expand All @@ -198,8 +199,8 @@ claude auth status
$LOOPCTL state
$LOOPCTL slo
# If you run the optional console, inspect its read-only /observability page too.
echo 'export LOOPKIT_AUTONOMY=on' > .ai/loops/config.env
export LOOPKIT_AUTONOMY=on
echo 'LOOPKIT_AUTONOMY=on' > .ai/loops/config.env
source "$LOOPKIT_REPO_ROOT/scripts/load-plane-env.sh"

# 5. Drop intent, then run the two beats (normally these run on a scheduler)
$LOOPCTL new "Add a deleteNote(id) function to src/notes.js with tests"
Expand All @@ -216,6 +217,13 @@ not a bug) · a target may carry `.claude/settings.json` to grant its workers pr
permissions · avoid hosting targets under `/tmp` on macOS (symlink canonicalization confuses
worker sandboxes) · don't run the beats from inside another sandboxed agent session.

Scheduled beat and console launchers must source `scripts/load-plane-env.sh` before `exec`-ing
Node. The helper deliberately exports assignment-only env files to the child process; merely
sourcing `LOOPKIT_AUTONOMY=on` without exporting it makes Node see an unset variable and report
the fail-safe halted state. Set `LOOPKIT_ENV_FILE` first when the env file is not
`.ai/loops/config.env`; standalone plane-home installs may use
`$LOOPKIT_HOME/config/autonomy.env`.

## The target contract

A repo becomes buildable by declaring a small, non-secret manifest (`loopkit.target.json`):
Expand Down
7 changes: 7 additions & 0 deletions packages/console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ console.log(`console listening on :${handle.port}`);
await handle.close();
```

When a scheduler or service manager launches the console, source the repository's
`scripts/load-plane-env.sh` helper before starting Node. A shell file containing
`LOOPKIT_AUTONOMY=on` is not enough by itself: without the helper's export step, the console
process sees the variable as unset and correctly—but misleadingly for the operator—renders the
fail-safe halted state. Set `LOOPKIT_ENV_FILE` before sourcing the helper when the deployment
keeps its environment outside `.ai/loops/config.env`.

## Screenshot

See [`docs/console.png`](../../docs/console.png) — the console's Command view rendered against a
Expand Down
122 changes: 122 additions & 0 deletions packages/core/test/load-plane-env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Regression coverage for scripts/load-plane-env.sh.
*
* A sourced shell assignment is not inherited by child processes unless it is exported. The
* console and beats are separate Node processes, so a launcher that merely sourced
* `LOOPKIT_AUTONOMY=on` made both surfaces fail safe to OFF even though the parent shell could
* print `on`. The versioned launcher helper must make assignment-only and explicit-export files
* equivalent.
*/

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const here = dirname(fileURLToPath(import.meta.url));
// test compiles to packages/core/dist-test/test/ -> repo root is four up
const repoRoot = join(here, '..', '..', '..', '..');
const helperPath = join(repoRoot, 'scripts', 'load-plane-env.sh');

function withEnvFile(contents: string, run: (path: string) => void): void {
const dir = mkdtempSync(join(tmpdir(), 'loopkit-plane-env-'));
const path = join(dir, 'autonomy.env');
writeFileSync(path, contents);
try {
run(path);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}

function sourceThenRead(envFile: string) {
return spawnSync(
'/bin/bash',
[
'-c',
'LOOPKIT_ENV_FILE="$1"; source "$2"; exec "$3" -e \'process.stdout.write(`${process.env.LOOPKIT_AUTONOMY}|${process.env.LOOPKIT_PLANE_MODE}`)\'',
'load-plane-env-test',
envFile,
helperPath,
process.execPath,
],
{ encoding: 'utf8' },
);
}

test('load-plane-env: plain assignments are exported to the launched process', () => {
withEnvFile('LOOPKIT_AUTONOMY=on\nLOOPKIT_PLANE_MODE=live\n', (envFile) => {
const result = sourceThenRead(envFile);
assert.equal(result.status, 0, String(result.stderr));
assert.equal(result.stdout, 'on|live');
});
});

test('load-plane-env: already-exported assignments remain supported', () => {
withEnvFile('export LOOPKIT_AUTONOMY=off\nexport LOOPKIT_PLANE_MODE=attended\n', (envFile) => {
const result = sourceThenRead(envFile);
assert.equal(result.status, 0, String(result.stderr));
assert.equal(result.stdout, 'off|attended');
});
});

test('load-plane-env: a standalone plane-home discovers config/autonomy.env', () => {
const planeHome = mkdtempSync(join(tmpdir(), 'loopkit-plane-home-'));
const configDir = join(planeHome, 'config');
mkdirSync(configDir);
writeFileSync(join(configDir, 'autonomy.env'), 'LOOPKIT_AUTONOMY=on\n');
try {
const result = spawnSync(
'/bin/bash',
[
'-c',
'LOOPKIT_HOME="$1"; source "$2"; exec "$3" -e \'process.stdout.write(process.env.LOOPKIT_AUTONOMY ?? "unset")\'',
'load-plane-env-test',
planeHome,
helperPath,
process.execPath,
],
{ encoding: 'utf8' },
);
assert.equal(result.status, 0, String(result.stderr));
assert.equal(result.stdout, 'on');
} finally {
rmSync(planeHome, { recursive: true, force: true });
}
});

test('load-plane-env: restores the caller allexport setting', () => {
withEnvFile('LOOPKIT_AUTONOMY=on\n', (envFile) => {
const result = spawnSync(
'/bin/bash',
[
'-c',
'set +a; LOOPKIT_ENV_FILE="$1"; source "$2"; [[ "$-" != *a* ]]',
'load-plane-env-test',
envFile,
helperPath,
],
{ encoding: 'utf8' },
);
assert.equal(result.status, 0, result.stderr);
});
});

test('load-plane-env: a missing explicit file fails loudly', () => {
const result = spawnSync(
'/bin/bash',
[
'-c',
'LOOPKIT_ENV_FILE="$1"; source "$2"',
'load-plane-env-test',
'/definitely/missing/loopkit.env',
helperPath,
],
{ encoding: 'utf8' },
);
assert.equal(result.status, 1);
assert.match(result.stderr, /plane environment is not readable/);
});
48 changes: 48 additions & 0 deletions scripts/load-plane-env.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
#
# load-plane-env.sh — source the plane's operator-controlled environment and export every
# assignment to child processes.
#
# This file MUST be sourced by a beat or console launcher:
#
# source /path/to/loopkit/scripts/load-plane-env.sh
# exec node ...
#
# Plain `NAME=value` assignments in a sourced file are shell-local by default. Without the
# temporary `allexport` below, Node sees the variable as unset and the console can report a
# halted plane while separately launched beats are armed.

if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
echo "load-plane-env.sh must be sourced by a launcher, not executed" >&2
exit 2
fi

_loopkit_load_plane_env() {
local env_file had_allexport=0 source_status

if [[ -n "${LOOPKIT_ENV_FILE:-}" ]]; then
env_file="$LOOPKIT_ENV_FILE"
elif [[ -n "${LOOPKIT_HOME:-}" && -f "$LOOPKIT_HOME/config/autonomy.env" ]]; then
env_file="$LOOPKIT_HOME/config/autonomy.env"
else
env_file=".ai/loops/config.env"
fi

if [[ ! -r "$env_file" ]]; then
echo "loopkit: plane environment is not readable: $env_file" >&2
return 1
fi

[[ "$-" == *a* ]] && had_allexport=1
set -a
# shellcheck disable=SC1090
source "$env_file"
source_status=$?
[[ "$had_allexport" -eq 1 ]] || set +a
return "$source_status"
}

_loopkit_load_plane_env
_loopkit_load_plane_env_status=$?
unset -f _loopkit_load_plane_env
return "$_loopkit_load_plane_env_status"
Loading