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
8 changes: 8 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ _Avoid_: agent, app
A continuing conversation between one chat anchor and one **Agent CLI**.
_Avoid_: thread, task

**Sender Identity**:
The best-effort identity of the person or Bot that authored one inbound chat
message. It follows each message turn rather than being fixed to a **Session**;
multiple senders may therefore appear in one Session. Human sender identity may
include an app-scoped open ID, display name, and email when the chat platform
makes them available. Missing optional fields never block message delivery.
_Avoid_: session owner, card recipient

**Token Usage**:
Token counts reported by an **Agent CLI** or its persisted transcript for a
**Session**. Token In is the Agent CLI's native input-side total, including
Expand Down
8 changes: 8 additions & 0 deletions src/bot-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ import type { FeedbackPolicy, FeedbackPolicyInput } from './services/feedback-po
import { normalizeFeedbackPolicyLayer } from './services/feedback-policy-resolver.js';
import type { FeedbackWebhookDestination } from './services/feedback-outbox.js';
import { cliModelSupportsReasoningEffort, isConfigurableReasoningCliId, isCodexReasoningEffort } from './services/codex-reasoning-effort.js';
import {
normalizeSessionOwnerReminderConfig,
type SessionOwnerReminderConfig,
} from './core/session-owner-reminder.js';
import type {
VcMeetingConsumerAgentConfig,
VcMeetingConsumerConfig,
Expand Down Expand Up @@ -1383,6 +1387,9 @@ export interface BotConfig {
* sessions are never suspended. See core/idle-worker-sweeper.ts.
*/
maxLiveWorkers?: number;
/** Periodically @ the persisted Session owner while selected actionable
* runtime states remain unchanged. Missing means disabled. */
sessionOwnerReminder?: SessionOwnerReminderConfig;
/**
* When true, THIS bot's daemon watches host load/memory and DMs the bot owner
* when the machine crosses into (and back out of) an overloaded state — a
Expand Down Expand Up @@ -2942,6 +2949,7 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] {
&& Number.isInteger(entry.maxLiveWorkers) && entry.maxLiveWorkers > 0
? entry.maxLiveWorkers
: undefined,
sessionOwnerReminder: normalizeSessionOwnerReminderConfig(entry.sessionOwnerReminder),
// Only explicit true persisted (undefined = off), same as restrictGrantCommands.
overloadAlert: entry.overloadAlert === true || undefined,
vcMeetingAgent,
Expand Down
31 changes: 23 additions & 8 deletions src/core/dashboard-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ import { cleanupMaterializedDashboardImages, materializeDashboardImages } from '
import { getCliDisplayName } from '../im/lark/card-builder.js';
import { sessionConfiguredRuntimeDisplayName } from './cli-runtime-display.js';
import { locateLimiter } from './dashboard-locate.js';
import { DEFAULT_SESSION_OWNER_REMINDER } from './session-owner-reminder.js';
import { updateSessionOwnerReminderConfig } from '../services/session-owner-reminder-config-store.js';
import { sendSessionOwnerThreadNotification } from '../services/session-owner-notification.js';
import { buildTerminalUrl } from './terminal-url.js';
import { dashboardEventBus } from './dashboard-events.js';
import { validateWorkingDir } from './working-dir.js';
Expand Down Expand Up @@ -2660,13 +2663,11 @@ ipcRoute('POST', '/api/sessions/:sessionId/locate', async (_req, res, params) =>
return jsonRes(res, 200, { ok: false, error: 'no_feishu_transport' });
}
try {
const messageId = await replyMessage(
ctx.larkAppId,
ctx.rootMessageId,
`<at user_id="${ctx.ownerOpenId}"></at>`,
'text',
true,
);
const messageId = await sendSessionOwnerThreadNotification({
larkAppId: ctx.larkAppId,
rootMessageId: ctx.rootMessageId,
ownerOpenId: ctx.ownerOpenId,
});
jsonRes(res, 200, { ok: true, messageId });
} catch (err) {
jsonRes(res, 502, { ok: false, error: String(err) });
Expand Down Expand Up @@ -3772,9 +3773,12 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => {
agentSelectionKey = selectionKeyForBot(cliId, wrapperCli ?? undefined);
} catch { /* no registered bot */ }
let maxLiveWorkers: number | null = null;
let sessionOwnerReminder = DEFAULT_SESSION_OWNER_REMINDER;
try {
const m = getBot(cachedLarkAppId).config.maxLiveWorkers;
const botConfig = getBot(cachedLarkAppId).config;
const m = botConfig.maxLiveWorkers;
if (typeof m === 'number' && Number.isInteger(m) && m > 0) maxLiveWorkers = m;
sessionOwnerReminder = botConfig.sessionOwnerReminder ?? DEFAULT_SESSION_OWNER_REMINDER;
} catch { /* default unlimited */ }
let logicalSessionCount = 0;
let residentSessionCount = 0;
Expand Down Expand Up @@ -3891,6 +3895,7 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => {
// value when this bot has no explicit override (prompt/global/off).
skillInjectionDefault: globalBuiltinSkillInjectionDefault(),
maxLiveWorkers,
sessionOwnerReminder,
logicalSessionCount,
residentSessionCount,
dormantSessionCount,
Expand Down Expand Up @@ -4828,6 +4833,16 @@ ipcRoute('PUT', '/api/bot-max-live-workers', async (req, res) => {
jsonRes(res, 200, { ok: true, maxLiveWorkers: value });
});

ipcRoute('PUT', '/api/bot-session-owner-reminder', async (req, res) => {
if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' });
let raw: unknown;
try { raw = await readJsonBody(req); }
catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); }
const result = await updateSessionOwnerReminderConfig(cachedLarkAppId, raw);
if (!result.ok) return jsonRes(res, 400, { ok: false, error: result.reason });
return jsonRes(res, 200, { ok: true, sessionOwnerReminder: result.config });
});

// Per-bot skill policy. Dashboard uses this for attach/detach; JSON policy
// still shares the same applyConfigField path as /botconfig.
ipcRoute('PUT', '/api/bot-skills', async (req, res) => {
Expand Down
3 changes: 2 additions & 1 deletion src/core/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,14 +735,15 @@ function renderChatContextBlock(chatContext?: ChatContext): string {

/**
* Render a `<sender>` tag for prompt injection. Caller resolves the sender
* (open_id + type + optional name) via `resolveSender(...)` in identity-cache.
* (open_id + type + optional name/email) via `resolveSender(...)` in identity-cache.
* Returns empty string when no sender data is available so the prompt stays
* clean for synthetic flows (scheduled tasks, no-op spawns).
*/
export function renderSenderTag(sender?: ResolvedSender): string {
if (!sender || !sender.openId) return '';
const attrs: string[] = [`type="${xmlEscape(sender.type)}"`, `open_id="${xmlEscape(sender.openId)}"`];
if (sender.name) attrs.push(`name="${xmlEscape(sender.name)}"`);
if (sender.email) attrs.push(`email="${xmlEscape(sender.email)}"`);
return `<sender ${attrs.join(' ')} />`;
}

Expand Down
234 changes: 234 additions & 0 deletions src/core/session-owner-reminder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import { createHash } from 'node:crypto';
import type { DaemonSession } from './types.js';

export const SESSION_OWNER_REMINDER_STATES = [
'idle',
'dormant',
'pending_repo',
'tui_prompt',
'agent_attention',
'limited',
] as const;

export type SessionOwnerReminderState = typeof SESSION_OWNER_REMINDER_STATES[number];

export interface SessionOwnerReminderConfig {
enabled: boolean;
intervalMinutes: number;
text: string;
states: SessionOwnerReminderState[];
}

export interface SessionOwnerReminderRecord {
sessionId: string;
stateFingerprint: string;
actionableSince: number;
lastObservedActivityAt: number;
lastRemindedAt?: number;
retryAfterAt?: number;
}

export type SessionOwnerReminderRecords = Record<string, SessionOwnerReminderRecord>;

export const DEFAULT_SESSION_OWNER_REMINDER: SessionOwnerReminderConfig = {
enabled: false,
intervalMinutes: 30,
text: '该会话已等待处理,请继续跟进。',
states: [...SESSION_OWNER_REMINDER_STATES],
};

const MIN_INTERVAL_MINUTES = 1;
const MAX_INTERVAL_MINUTES = 10_080;
const MAX_TEXT_CHARS = 500;
const FAILURE_RETRY_MAX_MS = 5 * 60_000;

function isState(value: unknown): value is SessionOwnerReminderState {
return typeof value === 'string'
&& (SESSION_OWNER_REMINDER_STATES as readonly string[]).includes(value);
}

/** Strict normalizer shared by config loading and write validation. */
export function normalizeSessionOwnerReminderConfig(
raw: unknown,
): SessionOwnerReminderConfig | undefined {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
const value = raw as Record<string, unknown>;
if (typeof value.enabled !== 'boolean') return undefined;
if (!Number.isInteger(value.intervalMinutes)
|| (value.intervalMinutes as number) < MIN_INTERVAL_MINUTES
|| (value.intervalMinutes as number) > MAX_INTERVAL_MINUTES) return undefined;
if (typeof value.text !== 'string') return undefined;
const text = value.text.trim();
if (!text || Array.from(text).length > MAX_TEXT_CHARS || /<\s*at\b/i.test(text)) return undefined;
if (!Array.isArray(value.states)) return undefined;
const states = [...new Set(value.states.filter(isState))];
if (states.length !== value.states.length) return undefined;
if (value.enabled && states.length === 0) return undefined;
return {
enabled: value.enabled,
intervalMinutes: value.intervalMinutes as number,
text,
states,
};
}

export function deriveSessionOwnerReminderStates(ds: DaemonSession): SessionOwnerReminderState[] {
const states: SessionOwnerReminderState[] = [];
const workerAlive = !!ds.worker && !ds.worker.killed;
if (workerAlive && ds.lastScreenStatus === 'idle') states.push('idle');
// A pre-spawn repository picker is its own actionable state, not a released
// CLI. Likewise stale screen status belongs to the old worker lifetime.
if (!workerAlive && !ds.pendingRepo) states.push('dormant');
if (ds.pendingRepo) states.push('pending_repo');
if (ds.tuiPromptCardId) states.push('tui_prompt');
if (ds.agentAttention) states.push('agent_attention');
if (workerAlive && ds.lastScreenStatus === 'limited') states.push('limited');
return states;
}

/**
* Observation fingerprint used both to detect a state transition (reset the
* quiet period) and to seed the per-cycle delivery UUID. It must capture not
* only WHICH runtime signals are present but also their INSTANCE identity for
* the signals that get replaced in place while the label stays constant:
*
* - `agent_attention` → a fresh `{kind, reason, at}` is written on every new
* attention raise, so key on `agentAttention.at`.
* - `tui_prompt` → an old TUI prompt card is cleared and a new
* `tuiPromptCardId` set between scans, so key on that id.
*
* Without the instance component, the old signal being resolved and a brand-new
* same-category signal appearing (e.g. a second, unrelated attention request)
* would inherit the previous signal's elapsed quiet time and could @ the owner
* almost immediately — undercutting the "give N minutes to handle THIS" promise.
*
* The remaining states (idle / dormant / pending_repo / limited) have no
* distinct replaceable instance, so their label alone is a faithful key.
*
* INVARIANT: for a fixed instance the fingerprint is stable across scans, so a
* within-cycle failed-send retry recomputes the SAME fingerprint → the same
* delivery UUID → the existing Lark dedupe window still suppresses duplicates.
* A new instance changes the fingerprint, which resets the record and thereby
* starts a fresh cycle whose eventual UUID is legitimately different.
*/
export function sessionOwnerReminderObservationFingerprint(
ds: DaemonSession,
states: SessionOwnerReminderState[],
): string {
return states
.map(state => {
if (state === 'agent_attention') return `agent_attention:${ds.agentAttention?.at ?? ''}`;
if (state === 'tui_prompt') return `tui_prompt:${ds.tuiPromptCardId ?? ''}`;
return state;
})
.join(',');
}

export interface SessionOwnerReminderControllerDeps {
load(): SessionOwnerReminderRecords;
save(records: SessionOwnerReminderRecords): void;
send(ds: DaemonSession, text: string, uuid: string): Promise<void>;
canSend(ds: DaemonSession): boolean;
onError?(ds: DaemonSession, error: unknown): void;
}

function recordsSnapshot(value: unknown): string {
return JSON.stringify(value);
}

export function sessionOwnerReminderDeliveryUuid(
sessionId: string,
stateFingerprint: string,
dueBase: number,
): string {
const digest = createHash('sha256')
.update(`${sessionId}\0${stateFingerprint}\0${dueBase}`, 'utf8')
.digest('hex')
.slice(0, 32);
return `owner-reminder-${digest}`;
}

/** Durable, deterministic scan engine. Scheduling and Lark IO are injected. */
export class SessionOwnerReminderController {
constructor(private readonly deps: SessionOwnerReminderControllerDeps) {}

async scan(
sessions: Iterable<DaemonSession>,
config: SessionOwnerReminderConfig,
now: number = Date.now(),
): Promise<void> {
const current = this.deps.load();
const before = recordsSnapshot(current);
if (!config.enabled || config.states.length === 0) {
if (Object.keys(current).length > 0) this.deps.save({});
return;
}

const configured = new Set(config.states);
const seen = new Set<string>();
const intervalMs = config.intervalMinutes * 60_000;

for (const ds of sessions) {
const sessionId = ds.session.sessionId;
if (ds.session.status !== 'active'
|| ds.session.queued === true
|| (ds.scope ?? ds.session.scope) !== 'thread'
|| !ds.session.ownerOpenId
|| !this.deps.canSend(ds)) {
delete current[sessionId];
continue;
}

const projectedStates = deriveSessionOwnerReminderStates(ds);
const matched = projectedStates.filter(state => configured.has(state));
if (matched.length === 0) {
delete current[sessionId];
continue;
}
seen.add(sessionId);
// Eligibility follows the configured subset, but timer resets follow the
// complete runtime state. An unselected attention signal still represents
// a state transition and starts a fresh quiet period. The fingerprint also
// captures instance identity (attention.at / tuiPromptCardId) so a NEW
// same-category signal replacing a resolved one resets the timer instead
// of inheriting the old quiet period — see the fingerprint helper above.
const stateFingerprint = sessionOwnerReminderObservationFingerprint(ds, projectedStates);
const activityAt = Number.isFinite(ds.lastMessageAt) ? ds.lastMessageAt : 0;
let record = current[sessionId];

if (!record
|| record.stateFingerprint !== stateFingerprint
|| activityAt > record.lastObservedActivityAt) {
record = current[sessionId] = {
sessionId,
stateFingerprint,
actionableSince: Math.max(now, activityAt),
lastObservedActivityAt: activityAt,
};
continue;
}

const dueBase = record.lastRemindedAt ?? record.actionableSince;
if (now - dueBase < intervalMs) continue;
if (record.retryAfterAt !== undefined && now < record.retryAfterAt) continue;

try {
await this.deps.send(
ds,
config.text,
sessionOwnerReminderDeliveryUuid(sessionId, stateFingerprint, dueBase),
);
record.lastRemindedAt = now;
record.retryAfterAt = undefined;
} catch (error) {
record.retryAfterAt = now + Math.max(60_000, Math.min(intervalMs, FAILURE_RETRY_MAX_MS));
this.deps.onError?.(ds, error);
}
}

for (const sessionId of Object.keys(current)) {
if (!seen.has(sessionId)) delete current[sessionId];
}
if (recordsSnapshot(current) !== before) this.deps.save(current);
}
}
2 changes: 1 addition & 1 deletion src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ export interface DaemonSession {
pendingAttachments?: LarkAttachment[];
pendingMentions?: LarkMention[]; // @mentions from initial message, used when building prompt after repo selection
pendingSubstituteTrigger?: import('../types.js').SubstituteTrigger;
/** Sender (open_id + type + resolved name) of the initial message — stashed
/** Sender (open_id + type + resolved name/email) of the initial message — stashed
* so the deferred spawn after repo-selection still injects a <sender> tag
* matching the original caller, not the user who clicked the card. */
pendingSender?: import('../im/lark/identity-cache.js').ResolvedSender;
Expand Down
Loading