Add selectable CASAN IDE integrations

This commit is contained in:
thanhnv
2026-07-23 23:04:54 +07:00
parent ff4e9d5a53
commit ce708fafe5
28 changed files with 1658 additions and 407 deletions
@@ -0,0 +1,13 @@
# CASAN Governed Chat for VS Code
This extension contributes an explicit `@casan` GitHub Copilot Chat route.
CASAN opens admission before the selected Copilot model is called and finalizes
the same trace after streaming completes.
It intentionally does **not** claim to intercept built-in Copilot chat. Use
`@casan <prompt>` when a CASAN-owned route is required. Claude Code and Codex
inside VS Code continue to use their own project lifecycle hooks installed by
`casan init`.
The extension has no runtime dependency beyond the VS Code API and Python 3
required by CASAN.
@@ -0,0 +1,189 @@
'use strict';
const vscode = require('vscode');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawn } = require('child_process');
const MAX_HOOK_OUTPUT = 1024 * 1024;
function projectRoot() {
const folders = vscode.workspace.workspaceFolders || [];
for (const folder of folders) {
const root = folder.uri.fsPath;
if (fs.existsSync(path.join(root, '.casan', 'config.json'))) {
return root;
}
}
return undefined;
}
function pythonCommand() {
const configured = vscode.workspace.getConfiguration('casan').get('pythonPath', '').trim();
if (configured) {
return { command: configured, prefix: [] };
}
return process.platform === 'win32'
? { command: 'py', prefix: ['-3'] }
: { command: 'python3', prefix: [] };
}
function runHook(root, event, payload, token) {
const bootstrap = path.join(root, '.casan', 'casan-hook.py');
if (!fs.existsSync(bootstrap)) {
return Promise.reject(new Error('Missing .casan/casan-hook.py. Run `casan init` again.'));
}
const runtime = pythonCommand();
const timeoutMs = vscode.workspace.getConfiguration('casan').get('hookTimeoutMs', 20000);
const args = [...runtime.prefix, bootstrap, '--client', 'vscode-copilot', '--event', event];
return new Promise((resolve, reject) => {
const child = spawn(runtime.command, args, {
cwd: root,
env: { ...process.env, CASAN_APP_ROOT: root },
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
let settled = false;
const finish = (error, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
cancellation.dispose();
if (error) reject(error); else resolve(value);
};
const timer = setTimeout(() => {
child.kill();
finish(new Error(`CASAN ${event} timed out after ${timeoutMs} ms`));
}, timeoutMs);
const cancellation = token.onCancellationRequested(() => {
child.kill();
finish(new vscode.CancellationError());
});
child.stdout.on('data', chunk => {
stdout += chunk.toString('utf8');
if (stdout.length > MAX_HOOK_OUTPUT) {
child.kill();
finish(new Error('CASAN hook output exceeded the safety limit'));
}
});
child.stderr.on('data', chunk => {
stderr += chunk.toString('utf8');
if (stderr.length > MAX_HOOK_OUTPUT) stderr = stderr.slice(-MAX_HOOK_OUTPUT);
});
child.on('error', error => finish(error));
child.on('close', code => {
let parsed;
try {
parsed = JSON.parse(stdout.trim() || '{}');
} catch (_error) {
finish(new Error(`CASAN ${event} returned invalid JSON${stderr ? `: ${stderr.trim()}` : ''}`));
return;
}
if (code !== 0 && parsed.decision !== 'block') {
finish(new Error(parsed.reason || stderr.trim() || `CASAN ${event} failed (${code})`));
return;
}
finish(undefined, parsed);
});
child.stdin.end(JSON.stringify(payload));
});
}
async function abortQuietly(root, admissionId, reason) {
if (!admissionId) return;
const source = new vscode.CancellationTokenSource();
try {
await runHook(root, 'Abort', { admission_id: admissionId, reason }, source.token);
} catch (_error) {
// The primary error is more useful to the user; abort remains best-effort.
} finally {
source.dispose();
}
}
async function handler(request, _context, stream, token) {
const root = projectRoot();
if (!root) {
stream.markdown('CASAN is not initialized in this workspace. Run `casan init` at the project root.');
return { metadata: { certified: false, reason: 'not_initialized' } };
}
if (!vscode.workspace.isTrusted) {
stream.markdown('CASAN requires a trusted VS Code workspace before it can run project governance.');
return { metadata: { certified: false, reason: 'workspace_untrusted' } };
}
if (request.command === 'status') {
stream.markdown('CASAN is initialized. Prompts sent explicitly to `@casan` use the governed route. Built-in Copilot chat is not globally intercepted.');
return { metadata: { certified: false, reason: 'status_only' } };
}
const sessionId = crypto.randomUUID();
const turnId = crypto.randomUUID();
const started = Date.now();
let admissionId;
try {
const begin = await runHook(root, 'Begin', {
project: root,
session_id: sessionId,
turn_id: turnId,
prompt: request.prompt,
client_version: vscode.version
}, token);
if (begin.decision === 'block' || !begin.admission_id) {
stream.markdown(`CASAN blocked this prompt: ${begin.reason || 'admission denied'}`);
return { metadata: { certified: false, traceId: begin.trace_id } };
}
admissionId = begin.admission_id;
stream.progress(`CASAN admission ${begin.trace_id || 'opened'}`);
const messages = [
vscode.LanguageModelChatMessage.User(
'You are operating through the CASAN governed chat route. Follow the user request, do not claim to have executed tools or changed files, and clearly state when a requested side effect requires a supported agentic client.'
),
vscode.LanguageModelChatMessage.User(request.prompt)
];
const response = await request.model.sendRequest(messages, {}, token);
let summary = '';
for await (const fragment of response.text) {
summary += fragment;
stream.markdown(fragment);
}
await runHook(root, 'Telemetry', {
admission_id: admissionId,
model: request.model.id,
runtime_ms: Date.now() - started,
cost_source: 'unavailable'
}, token);
const finalized = await runHook(root, 'Finalize', {
admission_id: admissionId,
stop_reason: 'completed',
assistant_summary: summary.slice(0, 4000)
}, token);
return {
metadata: {
certified: finalized.decision === 'certified',
traceId: finalized.trace_id,
certificationStrength: finalized.certification_strength
}
};
} catch (error) {
await abortQuietly(root, admissionId, token.isCancellationRequested
? 'vscode_cancelled'
: `vscode_error:${error instanceof Error ? error.message : String(error)}`);
if (error instanceof vscode.CancellationError) throw error;
stream.markdown(`CASAN governed chat failed safely: ${error instanceof Error ? error.message : String(error)}`);
return { metadata: { certified: false, reason: 'runtime_error' } };
}
}
function activate(context) {
const participant = vscode.chat.createChatParticipant('fpt-casan.casan', handler);
context.subscriptions.push(participant);
}
function deactivate() {}
module.exports = { activate, deactivate };
@@ -0,0 +1,56 @@
{
"name": "casan-governed-chat",
"displayName": "CASAN Governed Chat",
"description": "A CASAN-owned @casan route for GitHub Copilot Chat with H1-H7 admission and evidence.",
"version": "1.0.0",
"publisher": "fpt-casan",
"license": "UNLICENSED",
"engines": {
"vscode": "^1.98.0"
},
"categories": [
"AI",
"Chat"
],
"main": "./extension.js",
"activationEvents": [
"onChatParticipant:fpt-casan.casan"
],
"contributes": {
"chatParticipants": [
{
"id": "fpt-casan.casan",
"name": "casan",
"fullName": "CASAN Governed Chat",
"description": "Run this prompt through CASAN governance",
"isSticky": true,
"commands": [
{
"name": "status",
"description": "Show CASAN integration and certification status"
}
]
}
],
"configuration": {
"title": "CASAN",
"properties": {
"casan.pythonPath": {
"type": "string",
"default": "",
"description": "Python 3 executable used by the CASAN project bootstrap. Empty uses python3 (py -3 on Windows)."
},
"casan.hookTimeoutMs": {
"type": "number",
"default": 20000,
"minimum": 1000,
"maximum": 60000,
"description": "Maximum duration of each CASAN lifecycle bridge call."
}
}
}
},
"scripts": {
"check": "node --check extension.js"
}
}
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""VS Code/GitHub Copilot adapter for the CASAN-owned `@casan` participant.
The VS Code extension owns the request and native model call, while this thin
adapter maps participant lifecycle messages to the shared Plan-20 bridge. It
does not call a model and it does not claim to intercept built-in Copilot chat.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
_ADAPTER_DIR = os.path.dirname(os.path.abspath(__file__))
_BRIDGE_DIR = os.path.abspath(os.path.join(_ADAPTER_DIR, "..", "..", "scripts", "python"))
if _BRIDGE_DIR not in sys.path:
sys.path.insert(0, _BRIDGE_DIR)
import agentic_bridge as bridge # noqa: E402
ADAPTER_VERSION = "20.2.0-vscode"
def emit(value, exit_code=0):
sys.stdout.write(json.dumps(value, ensure_ascii=False) + "\n")
return exit_code
def handle_begin(payload):
response = bridge.op_begin({
"op": "begin",
"client": "vscode",
"client_version": payload.get("client_version"),
"adapter_version": ADAPTER_VERSION,
"project": payload.get("project") or payload.get("cwd"),
"session": payload.get("session_id"),
"turn": payload.get("turn_id"),
"prompt": payload.get("prompt", ""),
# The explicit @casan participant owns the complete model lifecycle.
"integration_mode": "casan_owned",
})
return emit(response, 2 if response.get("decision") == "block" else 0)
def handle_finalize(payload):
response = bridge.op_finalize({
"op": "finalize",
"admission_id": payload.get("admission_id", ""),
"stop_reason": payload.get("stop_reason") or "completed",
"assistant_summary": payload.get("assistant_summary"),
})
return emit(response)
def handle_abort(payload):
response = bridge.op_abort({
"op": "abort",
"admission_id": payload.get("admission_id", ""),
"reason": payload.get("reason") or "vscode_participant_aborted",
})
return emit(response)
def handle_telemetry(payload):
response = bridge.op_telemetry({
"op": "telemetry",
"admission_id": payload.get("admission_id", ""),
"model": payload.get("model"),
"runtime_ms": payload.get("runtime_ms"),
"input_tokens": payload.get("input_tokens"),
"output_tokens": payload.get("output_tokens"),
"cost_amount": payload.get("cost_amount"),
"cost_currency": payload.get("cost_currency"),
"cost_source": payload.get("cost_source"),
})
return emit(response)
HANDLERS = {
"Begin": handle_begin,
"Finalize": handle_finalize,
"Abort": handle_abort,
"Telemetry": handle_telemetry,
}
def main(argv=None):
parser = argparse.ArgumentParser(description="CASAN VS Code participant adapter")
parser.add_argument("--event", required=True, choices=sorted(HANDLERS))
args = parser.parse_args(argv)
try:
payload = json.loads(sys.stdin.read() or "{}")
except ValueError:
return emit({"decision": "block", "reason": "invalid_json"}, 2)
try:
return HANDLERS[args.event](payload)
except Exception as exc: # noqa: BLE001 - adapter boundary
return emit({"decision": "block", "reason": "adapter_error:%s" % exc}, 2)
if __name__ == "__main__":
raise SystemExit(main())