Skip to content

Commit 1b5dad6

Browse files
RoyLinRoyLin
authored andcommitted
feat: expose slash commands and cron scheduler API in Node.js and Python SDKs
Both SDKs now surface the full /loop command infrastructure via direct methods on the Session class, in addition to accepting slash command strings via session.send("/loop 5m prompt"). Node.js SDK — new Session methods: listCommands() → CommandInfo[] (name, description, usage?) scheduleTask(prompt, secs) → string (task ID) listScheduledTasks() → ScheduledTaskInfo[] cancelScheduledTask(id) → boolean New types added to index.d.ts: CommandInfo { name, description, usage? } ScheduledTaskInfo { id, prompt, intervalSecs, recurring, fireCount, nextFireInSecs } Python SDK — new Session methods: list_commands() → list[dict] register_command(name, desc, fn) → None (custom slash commands via Python callback) schedule_task(prompt, secs) → str (task ID) list_scheduled_tasks() → list[dict] cancel_scheduled_task(id) → bool register_command uses Python.with_gil() to call the callback synchronously from the Rust SlashCommand::execute() context (safe because send() releases the GIL before blocking via py.allow_threads()). Core changes: CommandRegistry.list_full() new method: includes usage hints AgentSession.command_registry Mutex<CommandRegistry> (interior mutability) AgentSession.register_command(&self) now takes &self (was &mut self)
1 parent 3a46fc5 commit 1b5dad6

5 files changed

Lines changed: 357 additions & 9 deletions

File tree

core/src/agent_api.rs

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1229,7 +1229,7 @@ impl Agent {
12291229
auto_save: opts.auto_save,
12301230
hook_engine: Arc::new(crate::hooks::HookEngine::new()),
12311231
init_warning,
1232-
command_registry,
1232+
command_registry: std::sync::Mutex::new(command_registry),
12331233
model_name: opts
12341234
.model
12351235
.clone()
@@ -1278,7 +1278,8 @@ pub struct AgentSession {
12781278
/// Deferred init warning: emitted as PersistenceFailed on first send() if set.
12791279
init_warning: Option<String>,
12801280
/// Slash command registry for `/command` dispatch.
1281-
command_registry: CommandRegistry,
1281+
/// Uses interior mutability so commands can be registered on a shared `Arc<AgentSession>`.
1282+
command_registry: std::sync::Mutex<CommandRegistry>,
12821283
/// Model identifier for display (e.g., "anthropic/claude-sonnet-4-20250514").
12831284
model_name: String,
12841285
/// Shared MCP manager — all add_mcp_server / remove_mcp_server calls go here.
@@ -1357,14 +1358,23 @@ impl AgentSession {
13571358
}
13581359
}
13591360

1360-
/// Get a reference to the slash command registry.
1361-
pub fn command_registry(&self) -> &CommandRegistry {
1362-
&self.command_registry
1361+
/// Get a snapshot of command entries (name, description, optional usage).
1362+
///
1363+
/// Acquires the command registry lock briefly and returns owned data.
1364+
pub fn command_registry(&self) -> std::sync::MutexGuard<'_, CommandRegistry> {
1365+
self.command_registry
1366+
.lock()
1367+
.expect("command_registry lock poisoned")
13631368
}
13641369

13651370
/// Register a custom slash command.
1366-
pub fn register_command(&mut self, cmd: Arc<dyn crate::commands::SlashCommand>) {
1367-
self.command_registry.register(cmd);
1371+
///
1372+
/// Takes `&self` so it can be called on a shared `Arc<AgentSession>`.
1373+
pub fn register_command(&self, cmd: Arc<dyn crate::commands::SlashCommand>) {
1374+
self.command_registry
1375+
.lock()
1376+
.expect("command_registry lock poisoned")
1377+
.register(cmd);
13681378
}
13691379

13701380
/// Access the session's cron scheduler (backs `/loop`, `/cron-list`, `/cron-cancel`).
@@ -1384,7 +1394,7 @@ impl AgentSession {
13841394
// Slash command interception
13851395
if CommandRegistry::is_command(prompt) {
13861396
let ctx = self.build_command_context();
1387-
if let Some(output) = self.command_registry.dispatch(prompt, &ctx) {
1397+
if let Some(output) = self.command_registry().dispatch(prompt, &ctx) {
13881398
return Ok(AgentResult {
13891399
text: output.text,
13901400
messages: history
@@ -1534,7 +1544,7 @@ impl AgentSession {
15341544
// Slash command interception for streaming
15351545
if CommandRegistry::is_command(prompt) {
15361546
let ctx = self.build_command_context();
1537-
if let Some(output) = self.command_registry.dispatch(prompt, &ctx) {
1547+
if let Some(output) = self.command_registry().dispatch(prompt, &ctx) {
15381548
let (tx, rx) = mpsc::channel(256);
15391549
let handle = tokio::spawn(async move {
15401550
let _ = tx

core/src/commands.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,23 @@ impl CommandRegistry {
191191
cmds
192192
}
193193

194+
/// Get all registered commands with name, description, and optional usage hint.
195+
pub fn list_full(&self) -> Vec<(String, String, Option<String>)> {
196+
let mut cmds: Vec<_> = self
197+
.commands
198+
.values()
199+
.map(|c| {
200+
(
201+
c.name().to_string(),
202+
c.description().to_string(),
203+
c.usage().map(|s| s.to_string()),
204+
)
205+
})
206+
.collect();
207+
cmds.sort_by(|a, b| a.0.cmp(&b.0));
208+
cmds
209+
}
210+
194211
/// Number of registered commands.
195212
pub fn len(&self) -> usize {
196213
self.commands.len()

sdk/node/index.d.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,63 @@ export declare class Session {
847847
* Removes all session-scoped memory items without affecting long-term or working memory.
848848
*/
849849
clearShortTerm(): Promise<void>
850+
/**
851+
* List all registered slash commands.
852+
*
853+
* Returns each command's name, description, and optional usage hint.
854+
* Slash commands can be invoked via `session.send("/command args")`.
855+
*
856+
* @returns Array of CommandInfo objects sorted by name
857+
*/
858+
listCommands(): Array<CommandInfo>
859+
/**
860+
* Schedule a recurring prompt to fire at a given interval.
861+
*
862+
* This is the programmatic equivalent of `/loop <interval>s <prompt>`.
863+
* The scheduled prompt runs automatically after each `send()` call when it is due.
864+
*
865+
* @param prompt - The prompt to send at each interval
866+
* @param intervalSecs - Interval in seconds (minimum: 1)
867+
* @returns 8-char hex task ID (use with `cancelScheduledTask`)
868+
*/
869+
scheduleTask(prompt: string, intervalSecs: number): string
870+
/**
871+
* List all active scheduled tasks for this session.
872+
*
873+
* @returns Array of ScheduledTaskInfo objects sorted by task ID
874+
*/
875+
listScheduledTasks(): Array<ScheduledTaskInfo>
876+
/**
877+
* Cancel a scheduled task by ID.
878+
*
879+
* @param id - Task ID returned by `scheduleTask` or listed by `listScheduledTasks`
880+
* @returns `true` if the task was found and cancelled
881+
*/
882+
cancelScheduledTask(id: string): boolean
883+
}
884+
/** Metadata about a registered slash command. */
885+
export interface CommandInfo {
886+
/** Command name without the leading `/` (e.g., `"loop"`, `"help"`) */
887+
name: string
888+
/** Short description shown in `/help` */
889+
description: string
890+
/** Optional usage hint (e.g., `"/loop [interval] <prompt> [every <interval>]"`) */
891+
usage?: string | null
892+
}
893+
/** Info about an active scheduled task. */
894+
export interface ScheduledTaskInfo {
895+
/** 8-char hex task ID */
896+
id: string
897+
/** The prompt sent at each interval */
898+
prompt: string
899+
/** Interval between fires in seconds */
900+
intervalSecs: number
901+
/** Whether the task repeats (always `true` for tasks created via `/loop`) */
902+
recurring: boolean
903+
/** Number of times this task has fired so far */
904+
fireCount: number
905+
/** Seconds until the next fire (0 if overdue) */
906+
nextFireInSecs: number
850907
}
851908
/**
852909
* Shared task board for team coordination.

sdk/node/src/lib.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1838,6 +1838,113 @@ impl Session {
18381838
.await
18391839
.map_err(|e| napi::Error::from_reason(format!("Task join error: {e}")))
18401840
}
1841+
1842+
// ========================================================================
1843+
// Slash Command & Scheduler API
1844+
// ========================================================================
1845+
1846+
/// List all registered slash commands.
1847+
///
1848+
/// Returns each command's name, description, and optional usage hint.
1849+
/// Slash commands can be invoked via `session.send("/command args")`.
1850+
///
1851+
/// @returns Array of CommandInfo objects sorted by name
1852+
#[napi]
1853+
pub fn list_commands(&self) -> Vec<CommandInfo> {
1854+
self.inner
1855+
.command_registry()
1856+
.list_full()
1857+
.into_iter()
1858+
.map(|(name, description, usage)| CommandInfo {
1859+
name,
1860+
description,
1861+
usage,
1862+
})
1863+
.collect()
1864+
}
1865+
1866+
/// Schedule a recurring prompt to fire at a given interval.
1867+
///
1868+
/// This is the programmatic equivalent of `/loop <interval>s <prompt>`.
1869+
/// The scheduled prompt runs automatically after each `send()` call when it is due.
1870+
///
1871+
/// @param prompt - The prompt to send at each interval
1872+
/// @param intervalSecs - Interval in seconds (minimum: 1)
1873+
/// @returns 8-char hex task ID (use with `cancelScheduledTask`)
1874+
#[napi]
1875+
pub fn schedule_task(&self, prompt: String, interval_secs: u32) -> napi::Result<String> {
1876+
self.inner
1877+
.cron_scheduler()
1878+
.create_task(
1879+
prompt,
1880+
std::time::Duration::from_secs(interval_secs as u64),
1881+
true,
1882+
)
1883+
.map_err(napi::Error::from_reason)
1884+
}
1885+
1886+
/// List all active scheduled tasks for this session.
1887+
///
1888+
/// @returns Array of ScheduledTaskInfo objects sorted by task ID
1889+
#[napi]
1890+
pub fn list_scheduled_tasks(&self) -> Vec<ScheduledTaskInfo> {
1891+
self.inner
1892+
.cron_scheduler()
1893+
.list_tasks()
1894+
.into_iter()
1895+
.map(|t| ScheduledTaskInfo {
1896+
id: t.id,
1897+
prompt: t.prompt,
1898+
interval_secs: t.interval_secs as i64,
1899+
recurring: t.recurring,
1900+
fire_count: t.fire_count as i64,
1901+
next_fire_in_secs: t.next_fire_in_secs as i64,
1902+
})
1903+
.collect()
1904+
}
1905+
1906+
/// Cancel a scheduled task by ID.
1907+
///
1908+
/// @param id - Task ID returned by `scheduleTask` or listed by `listScheduledTasks`
1909+
/// @returns `true` if the task was found and cancelled
1910+
#[napi]
1911+
pub fn cancel_scheduled_task(&self, id: String) -> bool {
1912+
self.inner.cron_scheduler().cancel_task(&id)
1913+
}
1914+
}
1915+
1916+
// ============================================================================
1917+
// Slash Command Types
1918+
// ============================================================================
1919+
1920+
/// Metadata about a registered slash command.
1921+
#[napi(object)]
1922+
#[derive(Clone)]
1923+
pub struct CommandInfo {
1924+
/// Command name without the leading `/` (e.g., `"loop"`, `"help"`)
1925+
pub name: String,
1926+
/// Short description shown in `/help`
1927+
pub description: String,
1928+
/// Optional usage hint (e.g., `"/loop [interval] <prompt> [every <interval>]"`)
1929+
pub usage: Option<String>,
1930+
}
1931+
1932+
/// Info about an active scheduled task.
1933+
#[napi(object)]
1934+
#[derive(Clone)]
1935+
pub struct ScheduledTaskInfo {
1936+
/// 8-char hex task ID
1937+
pub id: String,
1938+
/// The prompt sent at each interval
1939+
pub prompt: String,
1940+
/// Interval between fires in seconds
1941+
pub interval_secs: i64,
1942+
/// Whether the task repeats (always `true` for tasks created via `/loop`)
1943+
pub recurring: bool,
1944+
/// Number of times this task has fired so far
1945+
pub fire_count: i64,
1946+
/// Seconds until the next fire (0 if overdue)
1947+
pub next_fire_in_secs: i64,
18411948
}
18421949

18431950
// ============================================================================

0 commit comments

Comments
 (0)