mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
feat(discord): post a finished turn as the CLI's whole last message, stale thread cleanup, types moved to src/types
This commit is contained in:
1 parent
d6c7d30bd4
commit
093f242662
18 files changed
+779
-97
No files matched your search
@@ -10,6 +10,11 @@ import type { SessionPlacementHint } from '../../tab-layout-service.js';
|
||||
|
||||
export interface SessionPort {
|
||||
readonly sessions: ReadonlyMap<string, Session>;
|
||||
/**
|
||||
* The last-response reader (the body of `GET /api/sessions/:id/last-response`), filled in when the
|
||||
* session routes register, so other server code reads the same per-CLI transcript the viewer does.
|
||||
*/
|
||||
lastResponseFor?: (session: Session, context?: string) => Promise<{ text: string; timestamp?: string }>;
|
||||
/**
|
||||
* Register a created session with the owner's tab layout. `placement.tabGroupId`
|
||||
* puts it at the end of that group in the same layout write (ignored when the
|
||||
|
||||
@@ -3278,6 +3278,14 @@
|
||||
<option value="delete">Delete the thread</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">Stale threads</span><span class="set-row-desc">Threads this bot made that no tab uses any more, checked after startup and then daily. Threads under an hour old are skipped. Use one channel per Codeman.</span></div>
|
||||
<select id="discordStaleThreads" class="set-select">
|
||||
<option value="keep">Leave them</option>
|
||||
<option value="archive">Archive them</option>
|
||||
<option value="delete">Delete them</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field">
|
||||
<div class="set-row-text"><span class="set-row-label">@ ping</span></div>
|
||||
<select id="discordPingMode" class="set-select">
|
||||
|
||||
@@ -1767,6 +1767,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('discordAllowedUserIds').value = (d.allowedUserIds || []).join(', ');
|
||||
document.getElementById('discordThreadCreation').value = d.threadCreation;
|
||||
document.getElementById('discordOnSessionDelete').value = d.onSessionDelete;
|
||||
document.getElementById('discordStaleThreads').value = d.staleThreads || 'keep';
|
||||
document.getElementById('discordPingMode').value = d.pingMode;
|
||||
document.getElementById('discordScope').value = d.scope;
|
||||
document.getElementById('discordStartupHint').checked = d.startupHint !== false;
|
||||
@@ -1792,6 +1793,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
allowedUserIds: ids,
|
||||
threadCreation: document.getElementById('discordThreadCreation').value,
|
||||
onSessionDelete: document.getElementById('discordOnSessionDelete').value,
|
||||
staleThreads: document.getElementById('discordStaleThreads').value,
|
||||
pingMode: document.getElementById('discordPingMode').value,
|
||||
scope: document.getElementById('discordScope').value,
|
||||
startupHint: document.getElementById('discordStartupHint').checked,
|
||||
|
||||
@@ -27,10 +27,8 @@ import {
|
||||
readDiscordConfig,
|
||||
writeDiscordConfig,
|
||||
type DiscordBridge,
|
||||
type DiscordConfig,
|
||||
type DiscordGatewayState,
|
||||
type DiscordResult,
|
||||
} from '../../discord-bridge.js';
|
||||
import type { DiscordConfig, DiscordResult, DiscordStatus } from '../../types/discord.js';
|
||||
|
||||
export interface DiscordRouteDeps {
|
||||
bridge: DiscordBridge;
|
||||
@@ -39,14 +37,6 @@ export interface DiscordRouteDeps {
|
||||
hostTitle: () => string;
|
||||
}
|
||||
|
||||
export type DiscordStatus = Omit<DiscordConfig, 'token'> & {
|
||||
hasToken: boolean;
|
||||
tokenMasked: string;
|
||||
gateway: DiscordGatewayState;
|
||||
gatewayError: string | null;
|
||||
lastResult: DiscordResult | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A Docker case exports CODEMAN_SESSION_ID cut to 8 characters, so accept a unique prefix of at
|
||||
* least that length. Anything ambiguous or shorter is left as given and 404s like any unknown id.
|
||||
|
||||
@@ -2874,16 +2874,14 @@ export function registerSessionRoutes(
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
app.get('/api/sessions/:id/last-response', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
|
||||
// The body of GET /api/sessions/:id/last-response, so the Discord bridge reads the same transcript
|
||||
// the response viewer does (per-CLI reader, pane fallback) without a second copy of this logic.
|
||||
const lastResponseFor = async (session: Session, context?: string) => {
|
||||
// Codex sessions don't write to ~/.claude/projects — their transcripts
|
||||
// live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
|
||||
// response-viewer works for Codex panes too.
|
||||
if (getCli(session.mode)?.capabilities.transcript === 'codex-rollout') {
|
||||
const codexQuery = req.query as { context?: string };
|
||||
return await readCodexLastResponse(session, codexQuery.context === 'full');
|
||||
return await readCodexLastResponse(session, context === 'full');
|
||||
}
|
||||
|
||||
// DeepSeek Harness writes a real structured transcript under
|
||||
@@ -2902,8 +2900,7 @@ export function registerSessionRoutes(
|
||||
// would starve on an answer that exists. Those configurations keep the
|
||||
// pane segmenter below: coarse, but the real conversation.
|
||||
if (getCli(session.mode)?.capabilities.transcript === 'deepseek-zstd' && !session.docker && !session.remote) {
|
||||
const deepSeekQuery = req.query as { context?: string };
|
||||
const full = deepSeekQuery.context === 'full';
|
||||
const full = context === 'full';
|
||||
const transcript = await readDeepSeekLastResponse(session, { blocks: full });
|
||||
if (transcript) {
|
||||
return {
|
||||
@@ -2921,7 +2918,6 @@ export function registerSessionRoutes(
|
||||
// the pane IS the transcript for these CLIs. Codex is already handled above,
|
||||
// where a real rollout file is the better source.
|
||||
if (isExternalCliTranscriptMode(session.mode)) {
|
||||
const externalQuery = req.query as { context?: string };
|
||||
// A repainting full-screen TUI (copilot) streams absolute cursor moves, not lines, so the
|
||||
// buffer cannot be segmented; read the pane's visible screen instead, and fall back to the
|
||||
// buffer when there is no tmux pane to ask (direct PTY, tests).
|
||||
@@ -2934,7 +2930,7 @@ export function registerSessionRoutes(
|
||||
text: getLastTranscriptResponse(blocks),
|
||||
timestamp: '',
|
||||
hasContext: blocks.length > 0,
|
||||
messages: externalQuery.context === 'full' ? blocks : undefined,
|
||||
messages: context === 'full' ? blocks : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2960,11 +2956,10 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const query = req.query as { context?: string };
|
||||
// `turn` is the brief view's context: the last ANSWERED turn's assistant
|
||||
// messages, so a multi-row answer is not reduced to its final row. `text`
|
||||
// stays the last assistant row in every mode (agent pollers hash it).
|
||||
const wantsMessages = query.context === 'full' || query.context === 'turn';
|
||||
const wantsMessages = context === 'full' || context === 'turn';
|
||||
const claudeSessionId = session.claudeSessionId || session.id;
|
||||
const transcript = await findClaudeTranscript(projectsDir, claudeSessionId, session.id);
|
||||
if (!transcript) {
|
||||
@@ -2985,7 +2980,7 @@ export function registerSessionRoutes(
|
||||
try {
|
||||
const content = await fs.readFile(transcript.path, 'utf8');
|
||||
const parsed = parseClaudeResponseTranscript(content, wantsMessages);
|
||||
if (query.context === 'turn') {
|
||||
if (context === 'turn') {
|
||||
return {
|
||||
text: parsed.text,
|
||||
timestamp: parsed.timestamp,
|
||||
@@ -2996,6 +2991,13 @@ export function registerSessionRoutes(
|
||||
} catch {
|
||||
return wantsMessages ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
|
||||
}
|
||||
};
|
||||
ctx.lastResponseFor = lastResponseFor;
|
||||
|
||||
app.get('/api/sessions/:id/last-response', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
return lastResponseFor(session, (req.query as { context?: string }).context);
|
||||
});
|
||||
|
||||
function isCodexInjectedContext(text: string): boolean {
|
||||
|
||||
+2
-1
@@ -20,7 +20,7 @@ import {
|
||||
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
|
||||
import { CODEX_REASONING_EFFORTS } from '../types/session.js';
|
||||
import { WEBHOOK_KINDS, WEBHOOK_SCOPES } from '../types/push.js';
|
||||
import { DISCORD_ON_DELETE, DISCORD_PING_MODES, DISCORD_THREAD_CREATION } from '../discord-bridge.js';
|
||||
import { DISCORD_ON_DELETE, DISCORD_PING_MODES, DISCORD_THREAD_CREATION } from '../types/discord.js';
|
||||
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
|
||||
import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js';
|
||||
import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js';
|
||||
@@ -2229,6 +2229,7 @@ export const DiscordUpdateSchema = z
|
||||
allowedUserIds: z.array(z.string().max(25)).max(20).optional(),
|
||||
threadCreation: z.enum(DISCORD_THREAD_CREATION).optional(),
|
||||
onSessionDelete: z.enum(DISCORD_ON_DELETE).optional(),
|
||||
staleThreads: z.enum(DISCORD_ON_DELETE).optional(),
|
||||
pingMode: z.enum(DISCORD_PING_MODES).optional(),
|
||||
scope: z.enum(WEBHOOK_SCOPES).optional(),
|
||||
startupHint: z.boolean().optional(),
|
||||
|
||||
+36
-8
@@ -46,6 +46,7 @@ import { rebootRestoreRegistry } from './reboot-restore-registry.js';
|
||||
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
|
||||
import { WebhookNotifier, readWebhookConfig } from '../webhook-notify.js';
|
||||
import { DiscordBridge, screenTail } from '../discord-bridge.js';
|
||||
import { isExternalCliTranscriptMode } from './response-viewer-transcript.js';
|
||||
import { stopHostSensors } from '../host-sensors.js';
|
||||
import type { WebhookUrgency } from '../types/push.js';
|
||||
import { webviewFetch } from './webview-egress.js';
|
||||
@@ -509,6 +510,8 @@ export class WebServer extends EventEmitter {
|
||||
/** ntfy / Slack / Discord / generic webhook for the push events; config in webhook.json (0600). */
|
||||
private webhookNotifier = new WebhookNotifier(() => readWebhookConfig(getDataDir()), webviewFetch);
|
||||
/** Two-way Discord bridge (thread per tab); config in discord.json (0600). Idle until enabled in Settings. */
|
||||
/** Set by the session routes when they register (see SessionPort). */
|
||||
lastResponseFor?: (session: Session, context?: string) => Promise<{ text: string; timestamp?: string }>;
|
||||
private discordBridge = new DiscordBridge({
|
||||
configDir: getDataDir(),
|
||||
fetchImpl: webviewFetch,
|
||||
@@ -1334,6 +1337,8 @@ export class WebServer extends EventEmitter {
|
||||
// The registry's lifetime is the server's: it drops per-session wake state on every
|
||||
// cleanup path and resolves in-flight wakes on shutdown.
|
||||
this.remoteWake = registerSessionRoutes(this.app, ctx);
|
||||
// The routes' ctx is a separate object: take the last-response reader they registered on it (Discord alerts).
|
||||
this.lastResponseFor = (ctx as { lastResponseFor?: WebServer['lastResponseFor'] }).lastResponseFor;
|
||||
registerAgentSessionRoutes(this.app, ctx);
|
||||
registerRespawnRoutes(this.app, ctx);
|
||||
registerRalphRoutes(this.app, ctx);
|
||||
@@ -3013,6 +3018,9 @@ export class WebServer extends EventEmitter {
|
||||
// Discord: same event, same independence from Web Push. It adds the tail of what the CLI printed,
|
||||
// so the person reading the thread can see what is being asked without opening the tab.
|
||||
const dcSession = sessionId ? this.sessions.get(sessionId) : undefined;
|
||||
// A question or permission dialog lives on the screen, so it keeps the screen tail (its options are
|
||||
// there); a finished turn posts the CLI's whole last message instead, from its transcript.
|
||||
const dialogEvent = event === SseEvent.HookPermissionPrompt || event === SseEvent.HookElicitationDialog;
|
||||
void this.discordBridge
|
||||
.notify({
|
||||
event,
|
||||
@@ -3022,7 +3030,8 @@ export class WebServer extends EventEmitter {
|
||||
sessionId: sessionId || undefined,
|
||||
sessionName: sessionName || undefined,
|
||||
// A function, so the screen is only read (a synchronous tmux exec) once the bridge has decided to post.
|
||||
tail: dcSession ? () => this.discordTail(dcSession) : undefined,
|
||||
tail: dcSession ? () => this.discordTail(dcSession, !dialogEvent) : undefined,
|
||||
fullText: dcSession && !dialogEvent ? () => this.discordFullText(dcSession) : undefined,
|
||||
host: this.windowTitle,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
@@ -3113,9 +3122,25 @@ export class WebServer extends EventEmitter {
|
||||
* (`capture-pane -p`), never the raw output stream, which a full-screen UI repaints in place and
|
||||
* reads as garbage. A session with no pane falls back to its plain text output, else nothing.
|
||||
*/
|
||||
private discordTail(session: { muxName?: string | null; textOutput: string }): string {
|
||||
private discordTail(session: Session, cutComposer: boolean): string {
|
||||
const pane = session.muxName ? (this.mux.capturePaneText?.(session.muxName) ?? null) : null;
|
||||
return screenTail(pane ?? session.textOutput ?? '');
|
||||
// The composer row and the status lines under it are screen chrome, not what the CLI said.
|
||||
const glyph = cutComposer ? getCli(session.mode)?.capabilities.workDetect?.promptGlyph : undefined;
|
||||
return screenTail(pane ?? session.textOutput ?? '', 14, 900, glyph);
|
||||
}
|
||||
|
||||
/**
|
||||
* The CLI's whole last message from the response viewer's reader (the CLI's own transcript, or the pane
|
||||
* segmented into turns for the CLIs that keep none); undefined for a shell, which has no turns, and the
|
||||
* screen tail stands in whenever the reader finds nothing.
|
||||
*/
|
||||
private async discordFullText(session: Session): Promise<string | undefined> {
|
||||
if (!this.lastResponseFor) return undefined;
|
||||
// A CLI with a transcript reader, or whose pane the viewer segments into turns; a shell has neither.
|
||||
if (getCli(session.mode)?.capabilities.transcript === 'none' && !isExternalCliTranscriptMode(session.mode)) {
|
||||
return undefined;
|
||||
}
|
||||
return (await this.lastResponseFor(session)).text;
|
||||
}
|
||||
|
||||
/** The notification body for an event (shared by Web Push and the webhook channel). */
|
||||
@@ -3261,14 +3286,17 @@ export class WebServer extends EventEmitter {
|
||||
await this.finalizeRestoredState(restored);
|
||||
void this.discordBridge
|
||||
.release()
|
||||
.then(() =>
|
||||
// Same keep set as the agent inbox prune: a reboot-restore rebuild reuses the old id.
|
||||
this.discordBridge.prune([
|
||||
.then(async () => {
|
||||
// Only after a successful restore, like the agent inbox prune and the tab layout reconciliation, and
|
||||
// with the same keep set: a reboot-restore rebuild reuses the old id.
|
||||
if (!restored) return;
|
||||
await this.discordBridge.prune([
|
||||
...this.sessions.keys(),
|
||||
...Object.keys(this.store.getSessions()),
|
||||
...rebootRestoreRegistry.list(() => true).map((entry) => entry.sessionId),
|
||||
])
|
||||
)
|
||||
]);
|
||||
})
|
||||
.then(() => this.discordBridge.startStaleSweep())
|
||||
.catch(() => undefined);
|
||||
|
||||
// Instance-scoped reaper: after restore, `docker rm -f` managed containers of
|
||||
|
||||
Reference in new issue
Block a user