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 @@ -607,7 +607,7 @@ Tool behavior differs:
- targeted dashboard restore must use that exact id or fail loudly; it must not fall back to `--continue`
- `Codex`
- supports backend-session-id resume, so `migrate` usually takes the native resume path
- does **not** currently have a clean startup handoff flag, so `fork` uses seeded files plus an auto-submitted first-turn kickoff
- uses `developer_instructions` for clean startup and fork/migration continuity, with seeded files as durable carried-over context

For deeper details, see [docs/tool-integration.md](docs/tool-integration.md).

Expand Down Expand Up @@ -755,7 +755,8 @@ All tool behavior is config-driven. No tool-specific code exists in the multiple
"promptPatterns": ["^> $", "^\\$ $"],
"turnPatterns": ["^[>❯]\\s*(.+)"],
"compactCommand": "claude --print --output-format text",
"instructionsFile": "AGENTS.md"
"instructionsFile": "AGENTS.md",
"developerInstructionsConfigKey": "developer_instructions"
}
}
}
Expand All @@ -771,7 +772,14 @@ All tool behavior is config-driven. No tool-specific code exists in the multiple
| `promptPatterns` | Regex patterns for idle/prompt detection in status bar |
| `turnPatterns` | Regex patterns for extracting conversation turns from output |
| `compactCommand` | Shell command for LLM-powered history compaction |
| `instructionsFile` | File to write preamble to (for tools without system prompt flags) |
| `instructionsFile` | File to merge aimux's managed standing instructions into; user-authored content outside the managed block is preserved |
| `developerInstructionsConfigKey` | Codex config key for model-visible standing instructions, normally `developer_instructions`; set to `null` to rely only on `instructionsFile` |

Codex startup instructions use `-c developer_instructions=...` when configured, with `AGENTS.md` as the durable file fallback. Verify the installed Codex CLI exposes that channel with:

```bash
yarn verify:codex-instructions
```

## Multi-Client Runtime

Expand Down
33 changes: 11 additions & 22 deletions docs/tool-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ Each new tool should define as many of these as it can support:
- `promptPatterns`
- `turnPatterns`
- `instructionsFile`
- `developerInstructionsConfigKey`

## What A Good Integration Needs

Expand Down Expand Up @@ -146,43 +147,31 @@ At minimum:

## Current State

## Prompt Delivery
## Startup Instructions

Prompt injection is a shared runtime concern, not a per-feature detail.
Standing startup instructions should use a model-visible configuration or system/developer prompt channel whenever the tool provides one.

Production code that pushes text into a tmux-backed agent and expects it to run must be implemented in the runtime core replacement. Do not reintroduce the removed raw-input HTTP path.
Codex uses `-c developer_instructions=...` for fresh sessions, teammate sessions, forks, and migrations. Aimux also writes a managed block into the configured `instructionsFile` such as `AGENTS.md`; user-authored content outside that block is preserved.

Do not add new production paths that call `session.write(prompt + "\r")`, plain tmux `Enter`, or ad hoc delayed submits. Those paths can paste into Codex without actually submitting, especially when Codex collapses a large prompt into `[Pasted Content ...]`.
Do not reintroduce Codex startup prompt injection through tmux typing, delayed submits, or `session.write(prompt + "\r")`. Those paths can paste into Codex without actually submitting, especially when Codex collapses a large prompt into `[Pasted Content ...]`.

The shared delivery path owns:

- Codex single-line submit normalization
- waiting for the draft or pasted-content marker to appear
- waiting for the draft to stabilize before submitting
- raw carriage-return submission
- bounded retry when the draft remains visible after submit

Raw `session.write(prompt + "\r")` should only remain in non-tmux fallbacks or tests/standalone adapters that are not driving a live tmux pane.
If the Codex developer-instructions key is disabled with `developerInstructionsConfigKey: null`, aimux does not fall back to prompt injection. It relies on the managed instruction file instead. Verify a local Codex install with `yarn verify:codex-instructions`.

### Codex

- native backend resume path: yes
- prompt detection: yes
- tmux snapshot continuity: yes
- aimux fallback continuity: yes
- clean startup handoff flag: no
- clean startup handoff flag: yes, through `developer_instructions`
- audit note:
- native resume is the preferred path
- tmux-backed sessions can still lack structured `history/*.jsonl` or `live.md`, so the pane-snapshot fallback remains important
- `fork` therefore uses:
- detached tmux spawn
- seeded `.aimux/context/...` and `.aimux/plans/...` files
- an auto-submitted first-turn kickoff prompt
- that kickoff path is timing-sensitive and must be tested live if touched
- do not assume Codex fork startup behaves like Claude preamble startup
- do not submit Codex injected prompts with plain tmux `Enter`; use the shared aimux submit path that waits for the visible draft/pasted-content marker and sends raw carriage return
- keep Codex injected prompts single-line before submission; multiline pasted drafts are materially less reliable than the startup kickoff shape
- this applies to every retained push-injection path, not just fork/migrate: fresh preamble kickoff and future explicit prompt pushes must go through the same hardened submit logic
- continuity instructions passed through `developer_instructions`
- do not assume the managed `AGENTS.md` fallback carries per-session fork/migration context; the config channel is the durable path for that context

### Claude

Expand Down Expand Up @@ -225,11 +214,11 @@ When changing continuity code, verify all three of these separately:
Do not assume that fixing one path fixes the others:

- Claude fork uses preamble injection
- Codex fork uses a startup kickoff flow
- Codex fork uses developer instructions plus seeded continuity artifacts
- Codex migrate usually uses native backend resume
- Claude targeted restore uses native backend resume; fork/migrate still use aimux-owned continuity for handoff context

When changing prompt injection code, verify injected prompts are actually submitted, not merely pasted into the input buffer. For Codex, the failure mode is a visible `[Pasted Content ...]` draft or expanded prompt text that never starts running.
When changing Codex startup instruction code, verify the installed Codex CLI still exposes `developer_instructions` as developer-visible prompt input with `yarn verify:codex-instructions`.

Also keep the ownership boundary clear:

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"format": "prettier --write src/",
"format:check": "prettier --check src/",
"verify": "yarn typecheck && yarn lint && yarn test",
"verify:codex-instructions": "bash scripts/verify-codex-developer-instructions.sh",
"release:asset": "bash scripts/build-release-asset.sh",
"release:patch": "yarn version --patch && git push --follow-tags",
"release:minor": "yarn version --minor && git push --follow-tags",
Expand Down
40 changes: 40 additions & 0 deletions scripts/verify-codex-developer-instructions.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail

if ! command -v codex >/dev/null 2>&1; then
echo "codex CLI is not installed or not on PATH" >&2
exit 1
fi

instruction="aimux verification developer instructions $(date +%s)"
prompt="aimux verification user prompt"
config_value="$(node -e 'process.stdout.write(JSON.stringify(process.argv[1]))' "$instruction")"

output="$(codex debug prompt-input -c "developer_instructions=${config_value}" "$prompt")"

AIMUX_CODEX_PROMPT_INPUT="$output" node - "$instruction" "$prompt" <<'NODE'
const expectedInstruction = process.argv[2];
const expectedPrompt = process.argv[3];
const input = process.env.AIMUX_CODEX_PROMPT_INPUT ?? "";
const messages = JSON.parse(input);

function flattenText(message) {
return (message.content ?? [])
.map((part) => (part && typeof part.text === "string" ? part.text : ""))
.filter(Boolean)
.join("\n");
}

const developer = messages.find((message) => message.role === "developer" && flattenText(message).includes(expectedInstruction));
const user = messages.find((message) => message.role === "user" && flattenText(message).includes(expectedPrompt));

if (!developer) {
throw new Error("Codex did not expose developer_instructions as developer-visible prompt input");
}

if (!user) {
throw new Error("Codex debug prompt-input did not include the verification user prompt");
}

console.log("Codex developer_instructions channel verified");
NODE
5 changes: 4 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export interface StatuslineConfig {
}

export interface RuntimeConfig {
/** Whether aimux injects automatic session preamble / kickoff instructions. */
/** Whether aimux injects automatic session preamble instructions. */
agentPreambleEnabled: boolean;
tmux: TmuxRuntimeConfig;
}
Expand Down Expand Up @@ -92,6 +92,8 @@ export interface ToolConfig {
sessionIdFlag?: string[];
/** File to write preamble instructions to (created on start, removed on exit), e.g. "AGENTS.md" */
instructionsFile?: string;
/** Codex config key used for durable standing instructions, e.g. "developer_instructions" */
developerInstructionsConfigKey?: string | null;
/** Regex patterns that indicate the tool is idle/waiting for input */
promptPatterns?: string[];
/** Regex patterns to detect user prompts in terminal output (for turn extraction) */
Expand Down Expand Up @@ -157,6 +159,7 @@ const DEFAULT_CONFIG: AimuxConfig = {
resumeArgs: ["resume", "{sessionId}"],
resumeByBackendSessionId: true,
resumeFallback: ["resume", "--last"],
developerInstructionsConfigKey: "developer_instructions",
instructionsFile: "AGENTS.md",
promptPatterns: ["^> $"],
turnPatterns: ["^[>❯]\\s*(.+)"],
Expand Down
20 changes: 10 additions & 10 deletions src/multiplexer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,9 +583,18 @@ export class Multiplexer {
await this.contextWatcher.syncNow(sourceSessionId).catch(() => {});
const sourceSnapshot = this.sessionBootstrap.readForkSourceSnapshot(sourceSessionId);
this.sessionBootstrap.seedForkArtifacts(sourceSessionId, targetSessionId, targetToolConfigKey);
const codexContinuityPreamble = !toolCfg.preambleFlag
? this.sessionBootstrap.buildCodexForkContinuityPreamble(
sourceSessionId,
targetSessionId,
sourceSnapshot,
instruction,
)
: undefined;
const extraPreamble = [
this.sessionBootstrap.buildForkPreamble(sourceSessionId, targetSessionId),
instruction?.trim(),
codexContinuityPreamble ? undefined : instruction?.trim(),
codexContinuityPreamble,
]
.filter(Boolean)
.join("\n\n");
Expand All @@ -601,15 +610,6 @@ export class Multiplexer {
targetSessionId,
!toolCfg.preambleFlag,
);
if (!toolCfg.preambleFlag) {
const kickoff = this.sessionBootstrap.buildCodexForkKickoffPrompt(
sourceSessionId,
targetSessionId,
sourceSnapshot,
instruction,
);
await this.sessionBootstrap.deliverDetachedCodexKickoffPrompt(targetSessionId, kickoff, 1800);
}
this.agentTracker.emit(sourceSessionId, {
kind: "status",
message: `Forked ${targetSessionId} from this session`,
Expand Down
50 changes: 50 additions & 0 deletions src/multiplexer/runtime-lifecycle-methods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@ import { listTopologySessionStates, saveRuntimeTopologySessions } from "../runti

describe("runtime lifecycle state persistence", () => {
let repoRoot = "";
let originalCwd = "";

beforeEach(async () => {
originalCwd = process.cwd();
repoRoot = mkdtempSync(join(tmpdir(), "aimux-runtime-lifecycle-"));
mkdirSync(join(repoRoot, ".git"), { recursive: true });
await initPaths(repoRoot);
});

afterEach(() => {
process.chdir(originalCwd);
rmSync(repoRoot, { recursive: true, force: true });
});

Expand All @@ -41,6 +44,53 @@ describe("runtime lifecycle state persistence", () => {
return listTopologySessionStates({ statuses: ["running", "idle", "offline"] });
}

it("merges aimux instructions into existing AGENTS.md without overwriting user content", () => {
process.chdir(repoRoot);
const agentsPath = join(repoRoot, "AGENTS.md");
writeFileSync(agentsPath, "# Project Rules\n\nKeep this user rule.\n");
const writtenInstructionFiles = new Set<string>();

runtimeLifecycleMethods.writeInstructionFiles.call({
writtenInstructionFiles,
} as never);

const content = readFileSync(agentsPath, "utf-8");
expect(content).toContain("# Project Rules");
expect(content).toContain("Keep this user rule.");
expect(content).toContain("<!-- BEGIN Aimux MANAGED BLOCK: aimux-agent-instructions -->");
expect(content).toContain("# aimux Agent Instructions");
expect(content).toContain("<!-- END Aimux MANAGED BLOCK: aimux-agent-instructions -->");
expect(writtenInstructionFiles.size).toBe(1);
});

it("removes only aimux managed instructions during cleanup", () => {
process.chdir(repoRoot);
const agentsPath = join(repoRoot, "AGENTS.md");
writeFileSync(agentsPath, "# Project Rules\n\nKeep this user rule.\n");
const writtenInstructionFiles = new Set<string>();
const lifecycleHost = { writtenInstructionFiles } as never;

runtimeLifecycleMethods.writeInstructionFiles.call(lifecycleHost);
runtimeLifecycleMethods.removeInstructionFiles.call(lifecycleHost);

const content = readFileSync(agentsPath, "utf-8");
expect(content).toBe("# Project Rules\n\nKeep this user rule.\n");
});

it("deletes generated-only instruction files during cleanup", () => {
process.chdir(repoRoot);
const agentsPath = join(repoRoot, "AGENTS.md");
const writtenInstructionFiles = new Set<string>();
const lifecycleHost = { writtenInstructionFiles } as never;

runtimeLifecycleMethods.writeInstructionFiles.call(lifecycleHost);
expect(existsSync(agentsPath)).toBe(true);

runtimeLifecycleMethods.removeInstructionFiles.call(lifecycleHost);

expect(existsSync(agentsPath)).toBe(false);
});

it("does not expose topology sessions through the service state loader", () => {
writeFileSync(
getStatePath(),
Expand Down
55 changes: 47 additions & 8 deletions src/multiplexer/runtime-lifecycle-methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,42 @@ import {
syncSessionsFromTopology as syncSessionsFromTopologyImpl,
} from "./runtime-state.js";

const AIMUX_MANAGED_BLOCK_ID = "aimux-agent-instructions";
const AIMUX_MANAGED_BLOCK_START = `<!-- BEGIN Aimux MANAGED BLOCK: ${AIMUX_MANAGED_BLOCK_ID} -->`;
const AIMUX_MANAGED_BLOCK_END = `<!-- END Aimux MANAGED BLOCK: ${AIMUX_MANAGED_BLOCK_ID} -->`;

function managedInstructionBlock(content: string): string {
return `${AIMUX_MANAGED_BLOCK_START}\n${content.trim()}\n${AIMUX_MANAGED_BLOCK_END}`;
}

function mergeManagedInstructionBlock(existing: string, content: string): string {
const block = managedInstructionBlock(content);
const pattern = new RegExp(
`${escapeRegex(AIMUX_MANAGED_BLOCK_START)}[\\s\\S]*?${escapeRegex(AIMUX_MANAGED_BLOCK_END)}`,
"m",
);
if (pattern.test(existing)) {
return `${existing.replace(pattern, block).trim()}\n`;
}
const prefix = existing.trimEnd();
return `${prefix ? `${prefix}\n\n` : ""}${block}\n`;
}

function stripManagedInstructionBlock(existing: string): string {
const pattern = new RegExp(
`(?:\\n|^)\\s*${escapeRegex(AIMUX_MANAGED_BLOCK_START)}[\\s\\S]*?${escapeRegex(AIMUX_MANAGED_BLOCK_END)}\\s*(?=\\n|$)`,
"m",
);
return existing
.replace(pattern, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}

function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

function sanitizeOfflineSessionState(session: SessionState): SessionState {
const { tmuxTarget: _tmuxTarget, ...rest } = session;
return {
Expand Down Expand Up @@ -143,7 +179,7 @@ export const runtimeLifecycleMethods: RuntimeLifecycleMethods = {
const preamble =
"# aimux Agent Instructions\n\n" +
buildAimuxAgentInstructions() +
"\n\nThis file is auto-generated by aimux and will be removed when aimux exits.\n";
"\n\nThis managed block is written by aimux. User-authored content outside the block is preserved.\n";

let fullPreamble = preamble;
for (const mdPath of [join(homedir(), "AIMUX.md"), join(process.cwd(), "AIMUX.md")]) {
Expand All @@ -161,20 +197,23 @@ export const runtimeLifecycleMethods: RuntimeLifecycleMethods = {
for (const [, tool] of Object.entries(config.tools)) {
if (!tool.instructionsFile || !tool.enabled) continue;
const filePath = join(process.cwd(), tool.instructionsFile);
if (existsSync(filePath) && !mux.writtenInstructionFiles.has(filePath)) {
debug(`skipping ${tool.instructionsFile} — already exists`, "context");
continue;
}
writeFileSync(filePath, fullPreamble);
const existing = existsSync(filePath) ? readFileSync(filePath, "utf-8") : "";
writeFileSync(filePath, mergeManagedInstructionBlock(existing, fullPreamble));
mux.writtenInstructionFiles.add(filePath);
debug(`wrote ${tool.instructionsFile}`, "context");
debug(`merged aimux managed block into ${tool.instructionsFile}`, "context");
}
},
removeInstructionFiles(this: Multiplexer) {
const mux = this as unknown as RuntimeLifecycleHost;
for (const filePath of mux.writtenInstructionFiles) {
try {
unlinkSync(filePath);
if (!existsSync(filePath)) continue;
const cleaned = stripManagedInstructionBlock(readFileSync(filePath, "utf-8"));
if (cleaned) {
writeFileSync(filePath, `${cleaned}\n`);
} else {
unlinkSync(filePath);
}
} catch {}
}
mux.writtenInstructionFiles.clear();
Expand Down
Loading