Merge pull request #2648 from nanocoai/share-session-command

This commit is contained in:
gavrielc
2026-05-31 23:17:43 +03:00
committed by GitHub
5 changed files with 242 additions and 2 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ import { TIMEZONE, formatLocalTime } from './timezone.js';
*/
export type CommandCategory = 'admin' | 'filtered' | 'passthrough' | 'none';
const ADMIN_COMMANDS = new Set(['/remote-control', '/clear', '/compact', '/context', '/cost', '/files']);
const ADMIN_COMMANDS = new Set(['/remote-control', '/clear', '/compact', '/context', '/cost', '/files', '/upload-trace']);
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/start']);
export interface CommandInfo {
+14
View File
@@ -13,6 +13,7 @@ import {
stripInternalTags,
type RoutingContext,
} from './formatter.js';
import { isUploadTraceCommand, uploadTrace } from './upload-trace.js';
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
const POLL_INTERVAL_MS = 1000;
@@ -161,6 +162,19 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
commandIds.push(msg.id);
continue;
}
if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isUploadTraceCommand(msg)) {
log('Uploading session trace to Hugging Face');
writeMessageOut({
id: generateId(),
kind: 'chat',
platform_id: routing.platformId,
channel_type: routing.channelType,
thread_id: routing.threadId,
content: JSON.stringify({ text: uploadTrace() }),
});
commandIds.push(msg.id);
continue;
}
normalMessages.push(msg);
}
@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { initTestSessionDb, closeSessionDb, getInboundDb } from './db/connection.js';
import { getUndeliveredMessages } from './db/messages-out.js';
import { getPendingMessages } from './db/messages-in.js';
import type { MessageInRow } from './db/messages-in.js';
import { MockProvider } from './providers/mock.js';
import { runPollLoop } from './poll-loop.js';
import { isUploadTraceCommand } from './upload-trace.js';
beforeEach(() => {
initTestSessionDb();
});
afterEach(() => {
closeSessionDb();
});
describe('isUploadTraceCommand', () => {
const make = (text: unknown) => ({ content: JSON.stringify({ text }) }) as MessageInRow;
it('matches /upload-trace (case-insensitive, with args)', () => {
expect(isUploadTraceCommand(make('/upload-trace'))).toBe(true);
expect(isUploadTraceCommand(make('/UPLOAD-TRACE'))).toBe(true);
expect(isUploadTraceCommand(make(' /upload-trace now '))).toBe(true);
});
it('does not match other text or commands', () => {
expect(isUploadTraceCommand(make('hello'))).toBe(false);
expect(isUploadTraceCommand(make('/upload'))).toBe(false);
expect(isUploadTraceCommand(make('/clear'))).toBe(false);
expect(isUploadTraceCommand({ content: 'not json' } as MessageInRow)).toBe(false);
});
});
describe('poll loop — /upload-trace command', () => {
it('handles the command in the runner, writes a status, skips query', async () => {
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
VALUES ('m-upload-trace', 'chat', datetime('now'), 'pending', 'chan-1', 'discord', ?)`,
)
.run(JSON.stringify({ text: '/upload-trace' }));
// If the provider were ever queried it would emit this — asserting its
// absence proves the runner intercepted /upload-trace instead of the LLM.
const provider = new MockProvider({}, () => '<message to="discord-test">should not run</message>');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 5000);
await waitFor(() => getUndeliveredMessages().length > 0, 5000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
// A status line from uploadTrace() — never the provider's reply.
const text = JSON.parse(out[0].content).text as string;
expect(text.length).toBeGreaterThan(0);
expect(text).not.toBe('should not run');
// Command message was completed (not left pending).
expect(getPendingMessages()).toHaveLength(0);
await loopPromise.catch(() => {});
});
});
async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise<void> {
return Promise.race([
runPollLoop({ provider, providerName: 'mock', cwd: '/tmp' }),
new Promise<void>((_, reject) => {
signal.addEventListener('abort', () => reject(new Error('aborted')));
}),
new Promise<void>((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs)),
]);
}
async function waitFor(condition: () => boolean, timeoutMs: number): Promise<void> {
const start = Date.now();
while (!condition()) {
if (Date.now() - start > timeoutMs) throw new Error('waitFor timeout');
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
+142
View File
@@ -0,0 +1,142 @@
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { MessageInRow } from './db/messages-in.js';
/**
* `/upload-trace` command: upload this session's Claude Code transcript to the user's
* own private `{hf_user}/nanoclaw-traces` dataset, browsable in the HF Agent
* Trace Viewer. The transcript the Claude provider keeps under
* `~/.claude/projects/<dir>/<sessionId>.jsonl` is already in the format the
* viewer auto-detects, so this just locates the newest one and pushes it.
*
* Auth is the OneCLI gateway's job: curl goes out through the injected
* HTTPS_PROXY, which adds the user's HF token. We never see the raw token, and
* a 401 from `whoami` is our "not signed in" signal.
*/
/**
* Narrow check for /upload-trace — the runner handles this command directly
* (no LLM turn). Admin-gated by the host router before it reaches the container.
*/
export function isUploadTraceCommand(msg: MessageInRow): boolean {
let text = '';
try {
text = (JSON.parse(msg.content)?.text ?? '').trim();
} catch {
return false; // non-JSON content is never a command
}
return text.toLowerCase().startsWith('/upload-trace');
}
/** Newest Claude Code transcript jsonl (the current session). */
function newestTranscript(): string | null {
const projects = path.join(os.homedir(), '.claude', 'projects');
let best: { p: string; m: number } | null = null;
let dirs: string[];
try {
dirs = fs.readdirSync(projects);
} catch {
return null;
}
for (const dir of dirs) {
let files: string[];
try {
files = fs.readdirSync(path.join(projects, dir));
} catch {
continue;
}
for (const f of files) {
if (!f.endsWith('.jsonl')) continue;
const p = path.join(projects, dir, f);
const m = fs.statSync(p).mtimeMs;
if (!best || m > best.m) best = { p, m };
}
}
return best?.p ?? null;
}
function curl(args: string[], input?: string): { ok: boolean; out: string } {
const r = spawnSync('curl', args, { input, encoding: 'utf-8' });
return { ok: r.status === 0, out: (r.stdout ?? '') + (r.stderr ?? '') };
}
/** Returns a user-facing status line. Never throws. */
export function uploadTrace(): string {
const file = newestTranscript();
if (!file) return 'No transcript to upload for this session yet.';
const who = curl(['-sf', 'https://huggingface.co/api/whoami-v2']);
if (!who.ok) {
return [
"Can't upload — no Hugging Face token is available to this agent. To set it up:",
'',
'1. Create a token with WRITE access at https://huggingface.co/settings/tokens',
' (New token → type "Write" → copy it).',
'',
'2. Add it to the OneCLI vault. Open the dashboard — remotely at https://app.onecli.sh/',
' or on the host at http://127.0.0.1:10254 — then Secrets → New secret,',
' paste the token, and set the host pattern to huggingface.co',
'',
'3. Assign it to this agent — new agents start with no secrets attached.',
' In the same dashboard, open this agent and set its secret mode to "all"; or from the host run:',
' onecli agents list # find this agent\'s id',
' onecli agents set-secret-mode --id <agent-id> --mode all',
'',
'Then run /upload-trace again — no restart needed.',
].join('\n');
}
let user: string | undefined;
try {
user = JSON.parse(who.out)?.name;
} catch {
/* fall through */
}
if (!user) return 'Could not resolve your Hugging Face username.';
const repo = `${user}/nanoclaw-traces`;
// Idempotent create — ignore failure (already exists / no-op). The
// Content-Type header is required: without it curl sends form-encoding and
// the Hub rejects the body with 400 (expected string at "name").
curl([
'-sf',
'-X',
'POST',
'https://huggingface.co/api/repos/create',
'-H',
'Content-Type: application/json',
'-d',
JSON.stringify({ type: 'dataset', name: 'nanoclaw-traces', private: true }),
]);
const content = fs.readFileSync(file).toString('base64');
const repoPath = `sessions/${path.basename(file)}`;
const ndjson =
JSON.stringify({ key: 'header', value: { summary: 'add session trace' } }) +
'\n' +
JSON.stringify({
key: 'file',
value: { path: repoPath, encoding: 'base64', content },
}) +
'\n';
const commit = curl(
[
'-sf',
'-X',
'POST',
`https://huggingface.co/api/datasets/${repo}/commit/main`,
'-H',
'Content-Type: application/x-ndjson',
'--data-binary',
'@-',
],
ndjson,
);
if (!commit.ok) {
return 'Upload to Hugging Face failed (the transcript may be too large for an inline commit).';
}
return `Uploaded → https://huggingface.co/datasets/${repo}/blob/main/${repoPath}`;
}
+1 -1
View File
@@ -12,7 +12,7 @@ import { getDb, hasTable } from './db/connection.js';
export type GateResult = { action: 'pass' } | { action: 'filter' } | { action: 'deny'; command: string };
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/remote-control']);
const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/files']);
const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/files', '/upload-trace']);
/**
* Classify a message and decide whether it should reach the container.