Compare commits

...

14 Commits

Author SHA1 Message Date
gavrielc ca19b744b3 fix: exchange archives stamp local time
Archived: lines and archive filename dates in agent-readable conversation
markdown render in the install timezone instead of raw UTC ISO — evening
sessions west of UTC no longer archive under tomorrow's date.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2YZQDTw9TQrH3m8NBtVAW
2026-07-10 21:03:49 +03:00
gavrielc d2dd91b89f Merge pull request #2908 from amit-shafnir/providers-agnostic-surfaces
feat(codex): persona prepend + git-independent skill discovery for template agents
2026-07-05 15:14:30 +03:00
Amit Shafnir 40b6a57527 fix(codex): expose group skills at $HOME/.agents/skills so codex discovers them
Codex only scans the CWD-level `.agents/skills` when the CWD is inside a git
repo. The agent workspace (/workspace/agent) is not a git repo, so template and
shared skills materialized into /workspace/agent/.agents/skills were never
discovered. Codex DOES scan the user-level `$HOME/.agents/skills`
unconditionally (verified against codex-cli 0.141 at a non-git CWD), so mount
the group's `.agents` dir at /home/node/.agents as well. Skill materialization
stays in the provider-neutral group-skills.ts seam; only the exposure is here.

Also correct the AGENTS.md self-authored-skills pointer: `/workspace/agent/skills`
is persistent but not codex-scanned, and the "linked into $CODEX_HOME/skills at
boot" claim was never implemented. Point authored skills at `~/.codex/skills`
(writable via the .codex-shared mount and scanned by codex).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:17:53 +03:00
Amit Shafnir ed2e3b4c85 feat(codex): persona prepend + provider-agnostic skills mirror
codex-agents-md.ts inlines the group persona (readGroupPersona from the main seam)
as the first, never-evicted section of AGENTS.md. codex.ts mirrors template skills
into .agents/skills via the shared materializeTemplateSkills helper. Tests added
for both. Local-only commit to enable NANOCLAW_PROVIDERS_REF testing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:57:26 +03:00
gavrielc c57076649a Merge pull request #2836 from nanocoai/chore/bump-chat-sdk-4.29.0-providers
chore(deps): bump chat SDK to 4.29.0 (providers)
2026-06-23 15:28:18 +03:00
Gabi Simons d5dc9ef85c chore(deps): bump chat SDK to 4.29.0 (providers branch)
Companion to the core `chat` 4.29.0 bump on main and the @chat-adapter bump on
the channels branch. Pins `chat` and the channel-adapter install references on
the providers branch to the matched 4.29.0 release, so a provider-branch install
that also adds a channel stays on the same Chat SDK generation as main's bridge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:37:23 +03:00
omri-maya 842f6ba565 Merge pull request #2772 from nanocoai/fix/codex-per-thread-archive
fix(codex): per-thread conversation archive (CDX-004)
2026-06-15 17:00:50 +03:00
Omri Maya 780265225f chore(codex): trim filename comment to the non-obvious bit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:58:35 +03:00
Omri Maya 9fa85ccf95 chore(codex): drop ponytail comment
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:56:03 +03:00
Omri Maya dfd3ee31a9 fix(codex): derive date-prefix strip from regex, drop magic offset
f.slice(11) silently assumed len("YYYY-MM-DD-") and was coupled to the
`dated` regex two lines up — change the date format and it points at the
wrong offset while still "working". Strip with f.replace(dated, '') so the
prefix length lives in exactly one place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:54:34 +03:00
Koshkoshinsk 47f8296e67 fix(codex): date-prefix the per-thread archive filename, stable across days
Restore a sortable `YYYY-MM-DD-` prefix to the conversation archive filename
(parity with the Claude path) while keeping it thread-stable: reuse the
thread's existing file regardless of date so later exchanges keep appending to
one file, and only stamp the creation date when the file doesn't exist yet.
Exact-suffix match past the date prefix avoids substring collisions between
threads with shared prefixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:10:13 +03:00
Koshkoshinsk dab93fc592 feat(codex): append per-thread conversation archive
The Codex provider wrote one standalone file per exchange because its
app-server keeps conversation history server-side, with no on-disk
transcript to roll up at a compaction boundary. Key the archive file on
the thread/continuation id and append each completed exchange instead, so
a session lands in one growing file — matching the Claude path's
one-file-per-session granularity.

The thread-level header (provider, continuation id) is written once when
the file is created; each appended block carries its own timestamp and
status. Distinct threads still get distinct files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:10:13 +03:00
gavrielc 0ffe5582f0 refactor(codex): install Codex CLI via cli-tools.json manifest, not the Dockerfile
Trunk's adfae67 moved global Node CLI installs into container/cli-tools.json
(a json-merge seam) so a skill adds a CLI without editing the Dockerfile. The
Codex provider still hardcoded its CLI into the Dockerfile and guarded that
shape with a test. Migrate it:

- Drop the codex ARG + RUN from this branch's reference Dockerfile.
- Replace codex-dockerfile.test.ts with codex-cli-tools.test.ts, which asserts
  the @openai/codex entry in cli-tools.json (skips when the manifest is absent,
  e.g. on the bare providers branch).
- setup/providers/codex install-check verifies the manifest entry, not the
  Dockerfile.

Pairs with the trunk-side add-codex.sh / SKILL.md change that appends the
manifest entry instead of awk-ing the Dockerfile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:36:16 +03:00
gavrielc 4b5576faea Merge pull request #2757 from nanocoai/providers-codex-v2
feat(codex): Codex agent-provider payload v2 — app-server on capability seams, vault-only auth
2026-06-14 21:29:20 +03:00
33 changed files with 340 additions and 100 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ import './discord.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/discord@4.26.0
pnpm install @chat-adapter/discord@4.29.0
```
### 5. Build
+1 -1
View File
@@ -44,7 +44,7 @@ import './gchat.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/gchat@4.26.0
pnpm install @chat-adapter/gchat@4.29.0
```
### 5. Build
+1 -1
View File
@@ -48,7 +48,7 @@ import './github.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/github@4.26.0
pnpm install @chat-adapter/github@4.29.0
```
### 5. Build
+1 -1
View File
@@ -87,7 +87,7 @@ Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler n
### 5. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/linear@4.26.0
pnpm install @chat-adapter/linear@4.29.0
```
### 6. Build
+1 -1
View File
@@ -44,7 +44,7 @@ import './slack.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/slack@4.26.0
pnpm install @chat-adapter/slack@4.29.0
```
### 5. Build
+1 -1
View File
@@ -44,7 +44,7 @@ import './teams.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/teams@4.26.0
pnpm install @chat-adapter/teams@4.29.0
```
### 5. Build
+1 -1
View File
@@ -58,7 +58,7 @@ In `setup/index.ts`, add this entry to the `STEPS` map (right after the `registe
### 5. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/telegram@4.26.0
pnpm install @chat-adapter/telegram@4.29.0
```
### 6. Build
+1 -1
View File
@@ -44,7 +44,7 @@ import './whatsapp-cloud.js';
### 4. Install the adapter package (pinned)
```bash
pnpm install @chat-adapter/whatsapp@4.26.0
pnpm install @chat-adapter/whatsapp@4.29.0
```
### 5. Build
-4
View File
@@ -20,7 +20,6 @@ ARG INSTALL_CJK_FONTS=false
# mean every rebuild silently picks up the latest and can break in lockstep
# across all users.
ARG CLAUDE_CODE_VERSION=2.1.116
ARG CODEX_VERSION=0.138.0
ARG AGENT_BROWSER_VERSION=latest
ARG VERCEL_VERSION=latest
ARG BUN_VERSION=1.3.12
@@ -102,9 +101,6 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "agent-browser@${AGENT_BROWSER_VERSION}"
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@openai/codex@${CODEX_VERSION}"
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
@@ -0,0 +1,39 @@
// Structural guard for the Codex CLI install in container/cli-tools.json.
//
// @openai/codex is a CLI *binary* installed from the global-CLI manifest (a
// json-merge seam), not an importable package, so the barrel-driven
// registration tests cannot see it. This test reads the real cli-tools.json
// and asserts the @openai/codex entry is present and pinned to an exact
// version. It goes red if the manifest entry is dropped or unpins.
//
// Runs under bun (same suite as the container registration test):
// cd container/agent-runner && bun test src/providers/codex-cli-tools.test.ts
import { existsSync, readFileSync } from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
// container/agent-runner/src/providers/ -> container/cli-tools.json
const MANIFEST = path.join(import.meta.dir, '..', '..', '..', 'cli-tools.json');
const manifestPresent = existsSync(MANIFEST);
// Read lazily — `describe.skipIf` still runs the body to register tests, so the
// read has to be guarded for the bare-branch (no manifest) case.
const tools: Array<{ name: string; version: string }> = manifestPresent
? JSON.parse(readFileSync(MANIFEST, 'utf8'))
: [];
const codex = tools.find((t) => t.name === '@openai/codex');
// cli-tools.json is a trunk file; on the bare providers branch it isn't present,
// so skip there. In an installed tree (trunk + this payload) it must carry the
// pinned @openai/codex entry.
describe.skipIf(!manifestPresent)('container/cli-tools.json codex CLI install', () => {
it('includes the @openai/codex entry', () => {
expect(codex).toBeDefined();
});
it('pins it to an exact semver (no latest, no ranges)', () => {
expect(codex?.version).toMatch(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/);
});
});
@@ -1,30 +0,0 @@
// Structural guard for the Codex CLI install in container/Dockerfile.
//
// @openai/codex is a CLI *binary* installed via the Dockerfile, not an
// importable package, so the barrel-driven registration tests cannot see it.
// This test reads the real Dockerfile and asserts the version ARG and the
// `pnpm install -g` line for @openai/codex are both present. It goes red if
// either Dockerfile edit is dropped or drifts.
//
// Runs under bun (same suite as the container registration test):
// cd container/agent-runner && bun test src/providers/codex-dockerfile.test.ts
import { readFileSync } from 'fs';
import path from 'path';
import { describe, it, expect } from 'bun:test';
// container/agent-runner/src/providers/ -> container/Dockerfile
const DOCKERFILE = path.join(import.meta.dir, '..', '..', '..', 'Dockerfile');
describe('container/Dockerfile codex CLI install', () => {
const dockerfile = readFileSync(DOCKERFILE, 'utf8');
it('declares the CODEX_VERSION ARG', () => {
expect(dockerfile).toMatch(/ARG\s+CODEX_VERSION=/);
});
it('installs the @openai/codex CLI pinned to that ARG', () => {
expect(dockerfile).toMatch(/pnpm install -g\s+"@openai\/codex@\$\{CODEX_VERSION\}"/);
});
});
@@ -20,7 +20,7 @@ function makeTmpDir(): string {
}
describe('provider exchange archive', () => {
it('writes unique exchange-level archives with provider metadata', () => {
it('appends same-thread exchanges into one file with a single header', () => {
const conversationsDir = makeTmpDir();
const timestamp = new Date('2026-06-03T12:34:56.789Z');
@@ -43,17 +43,80 @@ describe('provider exchange archive', () => {
timestamp,
});
expect(first).not.toBeNull();
expect(second).not.toBeNull();
expect(first).not.toBe(second);
// Same thread → same date-prefixed, thread-stable file, not one per exchange.
expect(first).toBe('2026-06-03-codex-thread-123.md');
expect(second).toBe(first);
expect(fs.readdirSync(conversationsDir)).toHaveLength(1);
const content = fs.readFileSync(path.join(conversationsDir, first!), 'utf-8');
expect(content).toContain('# Codex Exchange');
// Header (thread-level metadata) written exactly once.
expect(content.match(/# Codex Conversation/g)).toHaveLength(1);
expect(content).toContain('Provider: codex');
expect(content).toContain('Continuation/thread id: thread-123');
expect(content).toContain('Status: completed');
// Both exchanges present, each with its own status line.
expect(content).toContain('**User**: hello');
expect(content).toContain('**Assistant**: world');
expect(content).toContain('**User**: hello again');
expect(content).toContain('**Assistant**: world again');
expect(content.match(/Status: completed/g)).toHaveLength(2);
});
it('writes a separate file per thread', () => {
const conversationsDir = makeTmpDir();
const timestamp = new Date('2026-06-03T12:34:56.789Z');
const a = archiveProviderExchange({
conversationsDir,
provider: 'codex',
prompt: 'p',
result: 'r',
continuation: 'thread-a',
status: 'completed',
timestamp,
});
const b = archiveProviderExchange({
conversationsDir,
provider: 'codex',
prompt: 'p',
result: 'r',
continuation: 'thread-b',
status: 'completed',
timestamp,
});
expect(a).toBe('2026-06-03-codex-thread-a.md');
expect(b).toBe('2026-06-03-codex-thread-b.md');
expect(fs.readdirSync(conversationsDir)).toHaveLength(2);
});
it('keeps the creation-date prefix stable when later exchanges land on another day', () => {
const conversationsDir = makeTmpDir();
const first = archiveProviderExchange({
conversationsDir,
provider: 'codex',
prompt: 'a',
result: 'b',
continuation: 'thread-x',
status: 'completed',
timestamp: new Date('2026-06-03T10:00:00.000Z'),
});
// A later exchange on a different day must append to the same file, not
// mint a new 2026-06-05-* one (the bug a naive date-from-timestamp scheme
// would introduce).
const second = archiveProviderExchange({
conversationsDir,
provider: 'codex',
prompt: 'c',
result: 'd',
continuation: 'thread-x',
status: 'completed',
timestamp: new Date('2026-06-05T10:00:00.000Z'),
});
expect(first).toBe('2026-06-03-codex-thread-x.md');
expect(second).toBe(first);
expect(fs.readdirSync(conversationsDir)).toHaveLength(1);
});
it('skips empty result text', () => {
@@ -1,11 +1,20 @@
import fs from 'fs';
import path from 'path';
import { TIMEZONE, formatLocalStamp } from '../timezone.js';
/**
* Per-exchange markdown archive for providers with no on-disk transcript
* Per-thread conversation archive for providers with no on-disk transcript
* payload code, shipped with the provider that needs it. The provider's
* `onExchangeComplete` hook (see types.ts) calls this with each completed
* exchange; the runner never archives on a provider's behalf.
*
* One file per thread (keyed on the continuation id), named
* `<date>-<provider>-<thread>.md` and appended to as exchanges complete
* mirroring the Claude path's one-file-per-session granularity and its
* date-prefixed, name-sortable filenames, since the Codex app-server keeps
* history server-side with no transcript to roll up at a compaction boundary.
* The date is the thread's creation day and stays stable across later appends.
*/
const DEFAULT_CONVERSATIONS_DIR = '/workspace/agent/conversations';
@@ -21,8 +30,10 @@ export interface ProviderExchangeArchiveOptions {
}
/**
* Archive a single prompt/result exchange. Returns the written filename, or
* null when there is nothing to archive (empty result).
* Append a single prompt/result exchange to its thread's conversation file,
* writing the thread-level header once when the file is first created. Returns
* the (thread-stable) filename, or null when there is nothing to archive
* (empty result).
*/
export function archiveProviderExchange(options: ProviderExchangeArchiveOptions): string | null {
const result = options.result?.trim();
@@ -33,43 +44,54 @@ export function archiveProviderExchange(options: ProviderExchangeArchiveOptions)
options.conversationsDir || process.env.NANOCLAW_CONVERSATIONS_DIR || DEFAULT_CONVERSATIONS_DIR;
fs.mkdirSync(conversationsDir, { recursive: true });
const filename = uniqueArchiveFilename(conversationsDir, options.provider, options.continuation, timestamp);
const lines = [
`# ${titleCase(options.provider)} Exchange`,
'',
`Archived: ${timestamp.toISOString()}`,
`Provider: ${options.provider}`,
`Continuation/thread id: ${options.continuation || '(none)'}`,
`Status: ${options.status}`,
const filename = threadArchiveFilename(conversationsDir, options.provider, options.continuation, timestamp);
const filePath = path.join(conversationsDir, filename);
// Thread-level metadata (provider, thread id) belongs in the header, written
// once. Per-exchange metadata (timestamp, status) rides in each appended
// block. Each block leads with a blank line + `---` so the separator renders
// as a thematic break, not a setext heading underline on the prior line.
const parts: string[] = [];
if (!fs.existsSync(filePath)) {
parts.push(
`# ${titleCase(options.provider)} Conversation`,
'',
`Provider: ${options.provider}`,
`Continuation/thread id: ${options.continuation || '(none)'}`,
);
}
parts.push(
'',
'---',
'',
`Archived: ${formatLocalStamp(timestamp, TIMEZONE)} · Status: ${options.status}`,
'',
`**User**: ${truncate(options.prompt)}`,
'',
`**Assistant**: ${truncate(result)}`,
'',
];
fs.writeFileSync(path.join(conversationsDir, filename), lines.join('\n'));
);
fs.appendFileSync(filePath, parts.join('\n'));
return filename;
}
function uniqueArchiveFilename(
function threadArchiveFilename(
dir: string,
provider: string,
continuation: string | undefined,
timestamp: Date,
): string {
const date = timestamp.toISOString().split('T')[0];
const time = timestamp.toISOString().replace(/[-:.TZ]/g, '').slice(8, 17);
const thread = sanitizeSlug(continuation || 'no-thread').slice(0, 24) || 'no-thread';
const base = `${date}-${sanitizeSlug(provider)}-${time}-${thread}`;
let filename = `${base}.md`;
let counter = 2;
while (fs.existsSync(path.join(dir, filename))) {
filename = `${base}-${counter}.md`;
counter += 1;
}
return filename;
const thread = sanitizeSlug(continuation || 'no-thread').slice(0, 48) || 'no-thread';
const suffix = `${sanitizeSlug(provider)}-${thread}.md`;
// Reuse this thread's existing file whatever day it was created; only stamp a
// new date when none exists. Match on the suffix after the date prefix.
const dated = /^\d{4}-\d{2}-\d{2}-/;
const existing = fs.readdirSync(dir).find((f) => dated.test(f) && f.replace(dated, '') === suffix);
if (existing) return existing;
// Local calendar day — the agent navigates conversations/ by these
// date-sortable names, and evening sessions west of UTC would otherwise
// land under tomorrow's date.
return `${formatLocalStamp(timestamp, TIMEZONE).slice(0, 10)}-${suffix}`;
}
function sanitizeSlug(value: string): string {
+16
View File
@@ -48,6 +48,22 @@ export function formatLocalTime(utcIso: string, timezone: string): string {
});
}
/**
* Compact sortable local stamp for log lines: "YYYY-MM-DD HH:mm" in `timezone`.
* (sv-SE is the one locale whose default rendering is this exact shape.)
*/
export function formatLocalStamp(date: Date, timezone: string): string {
return date.toLocaleString('sv-SE', {
timeZone: resolveTimezone(timezone),
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
function resolveContainerTimezone(): string {
const candidates = [process.env.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone];
for (const tz of candidates) {
+1 -1
View File
@@ -28,7 +28,7 @@
"@clack/prompts": "^1.2.0",
"@onecli-sh/sdk": "^0.3.1",
"better-sqlite3": "11.10.0",
"chat": "^4.24.0",
"chat": "4.29.0",
"cron-parser": "5.5.0",
"kleur": "^4.1.5"
},
+14 -5
View File
@@ -21,8 +21,8 @@ importers:
specifier: 11.10.0
version: 11.10.0
chat:
specifier: ^4.24.0
version: 4.26.0
specifier: 4.29.0
version: 4.29.0
cron-parser:
specifier: 5.5.0
version: 5.5.0
@@ -609,8 +609,17 @@ packages:
character-entities@2.0.2:
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
chat@4.26.0:
resolution: {integrity: sha512-QToDnIEGpyb8yQA6YLMHOSRK30YVk4RtsyFyuWFYyB2c4jQlyIrSWtwVK7qyvmvqzQp9uDwCdJRAhS8GtCHAGQ==}
chat@4.29.0:
resolution: {integrity: sha512-KdPfzaie5ivYytyRICTERg5xT+LeCbYefokvNAqTHe92eqkFaoTMXXkSitikxJVWhZIb2YoXF1b9UZHyzSzKzw==}
engines: {node: '>=20'}
peerDependencies:
ai: ^6.0.182
zod: ^3.0.0 || ^4.0.0
peerDependenciesMeta:
ai:
optional: true
zod:
optional: true
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
@@ -1963,7 +1972,7 @@ snapshots:
character-entities@2.0.2: {}
chat@4.26.0:
chat@4.29.0:
dependencies:
'@workflow/serde': 4.1.0-beta.2
mdast-util-to-string: 4.0.0
+1 -1
View File
@@ -15,7 +15,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-discord/SKILL.md.
ADAPTER_VERSION="@chat-adapter/discord@4.26.0"
ADAPTER_VERSION="@chat-adapter/discord@4.29.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
+1 -1
View File
@@ -15,7 +15,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-slack/SKILL.md.
ADAPTER_VERSION="@chat-adapter/slack@4.26.0"
ADAPTER_VERSION="@chat-adapter/slack@4.29.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
+1 -1
View File
@@ -18,7 +18,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-teams/SKILL.md.
ADAPTER_VERSION="@chat-adapter/teams@4.26.0"
ADAPTER_VERSION="@chat-adapter/teams@4.29.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
+1 -1
View File
@@ -15,7 +15,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"
# Keep in sync with .claude/skills/add-telegram/SKILL.md.
ADAPTER_VERSION="@chat-adapter/telegram@4.26.0"
ADAPTER_VERSION="@chat-adapter/telegram@4.29.0"
# Resolve which remote carries the channels branch — handles forks where
# upstream lives on a different remote than `origin`.
+1 -1
View File
@@ -37,7 +37,7 @@ if ! grep -q "import './discord.js';" src/channels/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/discord@4.26.0
pnpm install @chat-adapter/discord@4.29.0
echo "STEP: pnpm-build"
pnpm run build
+1 -1
View File
@@ -37,7 +37,7 @@ if ! grep -q "import './gchat.js';" src/channels/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/gchat@4.26.0
pnpm install @chat-adapter/gchat@4.29.0
echo "STEP: pnpm-build"
pnpm run build
+1 -1
View File
@@ -37,7 +37,7 @@ if ! grep -q "import './github.js';" src/channels/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/github@4.26.0
pnpm install @chat-adapter/github@4.29.0
echo "STEP: pnpm-build"
pnpm run build
+1 -1
View File
@@ -86,7 +86,7 @@ if ! grep -q 'if (config.catchAll) {' src/channels/chat-sdk-bridge.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/linear@4.26.0
pnpm install @chat-adapter/linear@4.29.0
echo "STEP: pnpm-build"
pnpm run build
+1 -1
View File
@@ -37,7 +37,7 @@ if ! grep -q "import './slack.js';" src/channels/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/slack@4.26.0
pnpm install @chat-adapter/slack@4.29.0
echo "STEP: pnpm-build"
pnpm run build
+1 -1
View File
@@ -37,7 +37,7 @@ if ! grep -q "import './teams.js';" src/channels/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/teams@4.26.0
pnpm install @chat-adapter/teams@4.29.0
echo "STEP: pnpm-build"
pnpm run build
+1 -1
View File
@@ -63,7 +63,7 @@ if ! grep -q "'pair-telegram':" setup/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/telegram@4.26.0
pnpm install @chat-adapter/telegram@4.29.0
echo "STEP: pnpm-build"
pnpm run build
+1 -1
View File
@@ -37,7 +37,7 @@ if ! grep -q "import './whatsapp-cloud.js';" src/channels/index.ts; then
fi
echo "STEP: pnpm-install"
pnpm install @chat-adapter/whatsapp@4.26.0
pnpm install @chat-adapter/whatsapp@4.29.0
echo "STEP: pnpm-build"
pnpm run build
+12 -4
View File
@@ -400,10 +400,18 @@ export function verifyCodexInstall(): { ok: boolean; problems: string[] } {
}
}
const dockerfilePath = path.join(root, 'container', 'Dockerfile');
const dockerfile = fs.existsSync(dockerfilePath) ? fs.readFileSync(dockerfilePath, 'utf-8') : '';
if (!/ARG CODEX_VERSION=/.test(dockerfile) || !dockerfile.includes('@openai/codex@${CODEX_VERSION}')) {
problems.push('container/Dockerfile missing the pinned @openai/codex install');
const manifestPath = path.join(root, 'container', 'cli-tools.json');
let hasCodexCli = false;
if (fs.existsSync(manifestPath)) {
try {
const tools = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Array<{ name?: string }>;
hasCodexCli = Array.isArray(tools) && tools.some((t) => t.name === '@openai/codex');
} catch {
hasCodexCli = false;
}
}
if (!hasCodexCli) {
problems.push('container/cli-tools.json missing the @openai/codex CLI entry');
}
return { ok: problems.length === 0, problems };
+70 -3
View File
@@ -19,6 +19,7 @@ vi.mock('../config.js', async (importOriginal) => ({
import { composeGroupAgentsMd, CODEX_PROJECT_DOC_MAX_BYTES } from './codex-agents-md.js';
import { closeDb, createAgentGroup, initTestDb, runMigrations } from '../db/index.js';
import { ensureContainerConfig, updateContainerConfigJson } from '../db/container-configs.js';
import { PERSONA_PREPEND_FILE } from '../group-persona.js';
import type { AgentGroup } from '../types.js';
const TEST_ROOT = '/tmp/nanoclaw-agents-md-test';
@@ -55,9 +56,10 @@ describe('composeGroupAgentsMd cap handling', () => {
composeGroupAgentsMd(g, groupDir);
const doc = fs.readFileSync(path.join(groupDir, 'AGENTS.md'), 'utf-8');
expect(doc).not.toContain('Omitted for size');
// Agent-authored skills must be told their persistent home — without
// this, authored skills land on ephemeral container paths and vanish.
expect(doc).toContain('/workspace/agent/skills');
// Agent-authored skills must be told a home that is BOTH persistent and
// codex-discovered (~/.codex/skills). /workspace/agent/skills is not
// scanned by codex, so authored skills there never trigger.
expect(doc).toContain('~/.codex/skills');
expect(Buffer.byteLength(doc, 'utf-8')).toBeLessThanOrEqual(CODEX_PROJECT_DOC_MAX_BYTES);
} finally {
fs.rmSync(groupDir, { recursive: true, force: true });
@@ -110,3 +112,68 @@ describe('composeGroupAgentsMd cap handling', () => {
}
});
});
describe('composeGroupAgentsMd persona', () => {
beforeEach(() => {
if (fs.existsSync(TEST_ROOT)) fs.rmSync(TEST_ROOT, { recursive: true });
fs.mkdirSync(path.join(TEST_ROOT, 'data'), { recursive: true });
runMigrations(initTestDb());
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_ROOT)) fs.rmSync(TEST_ROOT, { recursive: true });
});
it('inlines the persona as the first section, before the runtime contract', () => {
const g = group('persona');
createAgentGroup(g);
ensureContainerConfig(g.id);
const groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-md-'));
try {
fs.writeFileSync(path.join(groupDir, PERSONA_PREPEND_FILE), 'You are an SDR agent.\n');
composeGroupAgentsMd(g, groupDir);
const doc = fs.readFileSync(path.join(groupDir, 'AGENTS.md'), 'utf-8');
expect(doc).toContain('You are an SDR agent.');
// First markdown heading (the HEADER is an HTML comment, not a `# ` heading).
const firstHeading = doc.split('\n').find((line) => line.startsWith('# '));
expect(firstHeading).toBe('# Persona');
} finally {
fs.rmSync(groupDir, { recursive: true, force: true });
}
});
it('never evicts the persona even when the doc exceeds the cap', () => {
const g = group('persona-big');
createAgentGroup(g);
ensureContainerConfig(g.id);
updateContainerConfigJson(g.id, 'mcp_servers', {
bloat: { command: 'x', instructions: 'B'.repeat(CODEX_PROJECT_DOC_MAX_BYTES + 1024) },
});
const groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-md-'));
try {
fs.writeFileSync(path.join(groupDir, PERSONA_PREPEND_FILE), 'PERSONA_MARKER body');
composeGroupAgentsMd(g, groupDir);
const doc = fs.readFileSync(path.join(groupDir, 'AGENTS.md'), 'utf-8');
expect(Buffer.byteLength(doc, 'utf-8')).toBeLessThanOrEqual(CODEX_PROJECT_DOC_MAX_BYTES);
expect(doc).toContain('PERSONA_MARKER'); // survived eviction
expect(doc).toContain('Omitted for size');
} finally {
fs.rmSync(groupDir, { recursive: true, force: true });
}
});
it('omits the persona section when no prepend file is present', () => {
const g = group('no-persona');
createAgentGroup(g);
ensureContainerConfig(g.id);
const groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-md-'));
try {
composeGroupAgentsMd(g, groupDir);
const doc = fs.readFileSync(path.join(groupDir, 'AGENTS.md'), 'utf-8');
expect(doc).not.toContain('# Persona');
} finally {
fs.rmSync(groupDir, { recursive: true, force: true });
}
});
});
+7 -1
View File
@@ -19,6 +19,7 @@ import path from 'path';
import type { McpServerConfig } from '../container-config.js';
import { getContainerConfig } from '../db/container-configs.js';
import { readGroupPersona } from '../group-persona.js';
import { log } from '../log.js';
import type { AgentGroup } from '../types.js';
@@ -55,7 +56,7 @@ const NATIVE_RUNTIME_SKILLS_POINTER = [
'Selected NanoClaw runtime skills are available as Codex-native skills at `/workspace/agent/.agents/skills`.',
'Each skill directory contains a `SKILL.md` with its trigger description plus any supporting files, and points to the read-only shared skill source under `/app/skills`.',
'Use skill discovery to load these skills only when their descriptions match the task. Full skill instructions live in the skill directories, not in `AGENTS.md`.',
'Skills YOU author or install yourself go in `/workspace/agent/skills/<name>/SKILL.md` — persistent, provider-neutral (they load under any agent provider this group runs on), and yours to write and update over time. They are linked into `$CODEX_HOME/skills` automatically at boot. Never write skills anywhere else: paths outside your workspace and `$CODEX_HOME` are ephemeral.',
'Skills YOU author or install yourself go in `~/.codex/skills/<name>/SKILL.md` — persistent across sessions and discovered by Codex automatically. Never write skills elsewhere: paths outside `~/.codex` and `~/.agents` are ephemeral or not discovered.',
].join('\n\n');
interface AgentsMdSection {
@@ -80,6 +81,11 @@ export function composeGroupAgentsMd(group: AgentGroup, groupDir: string): void
if (body) sections.push({ name, content: `# ${name}\n\n${body}` });
};
// Template persona first — the top of the system prompt. 'Persona' is not a
// droppable prefix (see fitAgentsMdToCap.isDroppable), so it is never evicted.
const persona = readGroupPersona(groupDir);
if (persona) pushSection('Persona', persona);
const sharedBase = path.join(process.cwd(), 'container', 'AGENTS.md');
if (fs.existsSync(sharedBase)) {
pushSection('NanoClaw Runtime Contract', fs.readFileSync(sharedBase, 'utf-8'));
+31 -1
View File
@@ -88,11 +88,41 @@ describe('codex host contribution against real core', () => {
// The full mount set: codex surfaces in, default claude surfaces out.
const session = { id: 'session-1', agent_group_id: ag.id } as Session;
const config: ContainerConfig = { mcpServers: {}, packages: { apt: [], npm: [] }, additionalMounts: [], skills: [] };
const config: ContainerConfig = {
mcpServers: {},
packages: { apt: [], npm: [] },
additionalMounts: [],
skills: [],
};
const mounts = buildMounts(ag, session, config, 'codex', contribution);
const containerPaths = mounts.map((m) => m.containerPath);
expect(containerPaths).toContain('/home/node/.codex');
expect(containerPaths.some((p) => p.endsWith('AGENTS.md'))).toBe(true);
expect(containerPaths).not.toContain('/home/node/.claude');
});
it('mirrors per-group template skills from the Claude plane into .agents/skills', () => {
const ag = group('ag-codex-skills', 'codex-skills-group');
createAgentGroup(ag);
ensureContainerConfig(ag.id);
// A template stamps its skills as real dirs on the Claude plane; codex reads
// .agents/skills (RO-mounted), so the contribution must mirror them there.
const templateSkill = path.join(DATA_DIR, 'v2-sessions', ag.id, '.claude-shared', 'skills', 'widget');
fs.mkdirSync(templateSkill, { recursive: true });
fs.writeFileSync(path.join(templateSkill, 'SKILL.md'), '---\nname: widget\n---\n');
const contributionFn = getProviderContainerConfig('codex');
contributionFn!({
sessionDir: path.join(DATA_DIR, 'v2-sessions', ag.id, 'session-1'),
agentGroupId: ag.id,
groupDir: path.join(GROUPS_DIR, ag.folder),
selectedSkills: [],
hostEnv: process.env,
});
const mirrored = path.join(GROUPS_DIR, ag.folder, '.agents', 'skills', 'widget');
expect(fs.existsSync(path.join(mirrored, 'SKILL.md'))).toBe(true);
// A real dir, not a symlink — so it survives syncCodexSkillLinks' symlink-only prune.
expect(fs.lstatSync(mirrored).isSymbolicLink()).toBe(false);
});
});
+14
View File
@@ -31,6 +31,7 @@ import path from 'path';
import { DATA_DIR } from '../config.js';
import { getAgentGroup } from '../db/agent-groups.js';
import { materializeTemplateSkills } from '../group-skills.js';
import { composeGroupAgentsMd } from './codex-agents-md.js';
import { registerProviderContainerConfig } from './provider-container-registry.js';
@@ -52,6 +53,11 @@ registerProviderContainerConfig(
const group = getAgentGroup(ctx.agentGroupId);
if (group) composeGroupAgentsMd(group, ctx.groupDir);
syncCodexSkillLinks(ctx.groupDir, ctx.selectedSkills);
// Template skills live on the Claude plane (.claude-shared/skills); codex
// reads .agents/skills (RO-mounted), so mirror them here, host-side, via the
// shared provider-agnostic helper. Real dirs survive the symlink-only prune
// above and coexist with the shared-skill symlinks it creates.
materializeTemplateSkills(ctx.agentGroupId, path.join(ctx.groupDir, '.agents', 'skills'));
// No credential env here — OneCLI's container-config drives auth end to
// end: the gateway serves a sentinel auth.json stub into ~/.codex for
@@ -70,6 +76,14 @@ registerProviderContainerConfig(
const agentsDir = path.join(ctx.groupDir, '.agents');
if (fs.existsSync(agentsDir)) {
mounts.push({ hostPath: agentsDir, containerPath: '/workspace/agent/.agents', readonly: true });
// Codex only scans the CWD-level `.agents/skills` when the CWD is inside a
// git repo; the agent workspace (/workspace/agent) is not one, so skills
// materialized there are invisible. Codex DOES scan the user-level
// `$HOME/.agents/skills` unconditionally, so mount the same dir at $HOME
// to make the group's template + shared skills discoverable. Verified
// against codex-cli 0.141: user-level `.agents/skills` resolves at a
// non-git CWD. Skill materialization stays provider-neutral (group-skills.ts).
mounts.push({ hostPath: agentsDir, containerPath: '/home/node/.agents', readonly: true });
}
return { mounts };