sessioncast-cli 2.0.9 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,13 @@ export declare class ApiWebSocketClient {
20
20
  private handleLlmChat;
21
21
  private handleSendKeys;
22
22
  private handleListSessions;
23
+ /**
24
+ * Handle capability_request from relay — evaluate each requested capability
25
+ * against the agent's config and respond with capability_grant.
26
+ *
27
+ * Config values: true = auto-grant, false = auto-deny, 'ask' = deny + log notice
28
+ */
29
+ private handleCapabilityRequest;
23
30
  private sendApiResponse;
24
31
  private send;
25
32
  private scheduleReconnect;
@@ -138,6 +138,9 @@ class ApiWebSocketClient {
138
138
  case 'list_sessions':
139
139
  await this.handleListSessions(message);
140
140
  break;
141
+ case 'capability_request':
142
+ this.handleCapabilityRequest(message);
143
+ break;
141
144
  }
142
145
  }
143
146
  async handleExec(message) {
@@ -212,7 +215,18 @@ class ApiWebSocketClient {
212
215
  try {
213
216
  console.log('[API] list_sessions');
214
217
  const sessions = tmux.listSessions();
215
- this.sendApiResponse(meta.requestId, { sessions });
218
+ const enriched = sessions.map(s => {
219
+ const cwd = tmux.getPaneCwd(s.name);
220
+ const git = cwd ? tmux.getGitInfo(cwd) : null;
221
+ return {
222
+ ...s,
223
+ cwd: cwd || undefined,
224
+ gitBranch: git?.branch || undefined,
225
+ gitRemote: git?.remote || undefined,
226
+ gitRepo: git?.repo || undefined
227
+ };
228
+ });
229
+ this.sendApiResponse(meta.requestId, { sessions: enriched });
216
230
  }
217
231
  catch (error) {
218
232
  this.sendApiResponse(meta.requestId, {
@@ -221,6 +235,47 @@ class ApiWebSocketClient {
221
235
  });
222
236
  }
223
237
  }
238
+ /**
239
+ * Handle capability_request from relay — evaluate each requested capability
240
+ * against the agent's config and respond with capability_grant.
241
+ *
242
+ * Config values: true = auto-grant, false = auto-deny, 'ask' = deny + log notice
243
+ */
244
+ handleCapabilityRequest(message) {
245
+ const meta = message.meta;
246
+ if (!meta)
247
+ return;
248
+ const from = meta.from;
249
+ const requestedStr = meta.capabilities || '';
250
+ const requested = requestedStr.split(',').map(s => s.trim()).filter(Boolean);
251
+ const capConfig = this.apiConfig.capabilities || {};
252
+ const granted = [];
253
+ const denied = [];
254
+ for (const cap of requested) {
255
+ const setting = capConfig[cap];
256
+ if (setting === true) {
257
+ granted.push(cap);
258
+ }
259
+ else if (setting === 'ask') {
260
+ // Future: interactive prompt. For now, deny and log notice.
261
+ console.log(`[API] Capability '${cap}' requires user consent (configured as 'ask'). Denying by default.`);
262
+ denied.push(cap);
263
+ }
264
+ else if (setting === false || setting === undefined) {
265
+ // Explicitly denied or not configured → deny
266
+ denied.push(cap);
267
+ }
268
+ }
269
+ console.log(`[API] Capability request from SDK: requested=[${requested}], granted=[${granted}], denied=[${denied}]`);
270
+ this.send({
271
+ type: 'capability_grant',
272
+ meta: {
273
+ from: from || '',
274
+ granted: granted.join(','),
275
+ denied: denied.join(',')
276
+ }
277
+ });
278
+ }
224
279
  sendApiResponse(requestId, response) {
225
280
  this.send({
226
281
  type: 'api_response',
@@ -0,0 +1,3 @@
1
+ export declare function setDebug(enabled: boolean): void;
2
+ export declare function isDebug(): boolean;
3
+ export declare function debugLog(tag: string, ...args: any[]): void;
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.setDebug = setDebug;
4
+ exports.isDebug = isDebug;
5
+ exports.debugLog = debugLog;
6
+ let debugEnabled = false;
7
+ function setDebug(enabled) {
8
+ debugEnabled = enabled;
9
+ }
10
+ function isDebug() {
11
+ return debugEnabled;
12
+ }
13
+ function debugLog(tag, ...args) {
14
+ if (debugEnabled) {
15
+ console.log(`[DEBUG][${tag}]`, ...args);
16
+ }
17
+ }
@@ -184,7 +184,7 @@ class AgentRunner {
184
184
  const handler = new session_handler_1.TmuxSessionHandler({
185
185
  config: this.config,
186
186
  tmuxSession,
187
- onCreateSession: (name) => this.createTmuxSession(name)
187
+ onCreateSession: (name, workingDir) => this.createTmuxSession(name, workingDir)
188
188
  });
189
189
  this.handlers.set(tmuxSession, handler);
190
190
  handler.start();
@@ -199,7 +199,7 @@ class AgentRunner {
199
199
  console.log(`Stopped handler for session: ${this.config.machineId}/${tmuxSession}`);
200
200
  }
201
201
  }
202
- createTmuxSession(sessionName) {
202
+ createTmuxSession(sessionName, workingDir) {
203
203
  // Sanitize session name
204
204
  const sanitized = sessionName.replace(/[^a-zA-Z0-9_-]/g, '_');
205
205
  if (!sanitized) {
@@ -210,8 +210,8 @@ class AgentRunner {
210
210
  console.warn(`Session already exists: ${sanitized}`);
211
211
  return;
212
212
  }
213
- console.log(`Creating new tmux session: ${sanitized}`);
214
- if (tmux.createSession(sanitized)) {
213
+ console.log(`Creating new tmux session: ${sanitized}${workingDir ? ` (cwd: ${workingDir})` : ''}`);
214
+ if (tmux.createSession(sanitized, workingDir)) {
215
215
  console.log(`Successfully created tmux session: ${sanitized}`);
216
216
  // Force immediate scan
217
217
  this.scanAndUpdateSessions();
@@ -2,7 +2,7 @@ import { AgentConfig } from './types';
2
2
  interface SessionHandlerOptions {
3
3
  config: AgentConfig;
4
4
  tmuxSession: string;
5
- onCreateSession?: (name: string) => void;
5
+ onCreateSession?: (name: string, workingDir?: string) => void;
6
6
  }
7
7
  export declare class TmuxSessionHandler {
8
8
  private config;
@@ -17,6 +17,8 @@ export declare class TmuxSessionHandler {
17
17
  private captureTimer;
18
18
  private lastPaneIds;
19
19
  private lastPaneScreens;
20
+ private currentMetaJson;
21
+ private metaCheckTimer;
20
22
  private pendingUploads;
21
23
  private uploadDir;
22
24
  constructor(options: SessionHandlerOptions);
@@ -50,6 +52,10 @@ export declare class TmuxSessionHandler {
50
52
  * Get content type from file extension
51
53
  */
52
54
  private getContentType;
55
+ private handleCreateWorktree;
56
+ private startMetaTracking;
57
+ private stopMetaTracking;
58
+ private checkAndSendMeta;
53
59
  private handleKeys;
54
60
  private startScreenCapture;
55
61
  private stopScreenCapture;
@@ -38,7 +38,9 @@ const websocket_1 = require("./websocket");
38
38
  const tmux = __importStar(require("./tmux"));
39
39
  const fs = __importStar(require("fs"));
40
40
  const path = __importStar(require("path"));
41
+ const child_process_1 = require("child_process");
41
42
  const sentry_1 = require("../sentry");
43
+ const debug_1 = require("./debug");
42
44
  // Capture intervals
43
45
  const CAPTURE_INTERVAL_ACTIVE_MS = 50;
44
46
  const CAPTURE_INTERVAL_IDLE_MS = 200;
@@ -46,6 +48,7 @@ const ACTIVE_THRESHOLD_MS = 2000;
46
48
  const FORCE_SEND_INTERVAL_MS = 10000;
47
49
  const USE_COMPRESSION = true;
48
50
  const MIN_COMPRESS_SIZE = 512;
51
+ const META_CHECK_INTERVAL_MS = 15000;
49
52
  class TmuxSessionHandler {
50
53
  constructor(options) {
51
54
  this.wsClient = null;
@@ -57,6 +60,9 @@ class TmuxSessionHandler {
57
60
  // Multi-pane tracking
58
61
  this.lastPaneIds = '';
59
62
  this.lastPaneScreens = new Map();
63
+ // Metadata tracking (cwd + git info)
64
+ this.currentMetaJson = '';
65
+ this.metaCheckTimer = null;
60
66
  // File upload handling
61
67
  this.pendingUploads = new Map();
62
68
  this.uploadDir = process.cwd(); // Default to current working directory
@@ -88,6 +94,7 @@ class TmuxSessionHandler {
88
94
  this.wsClient.on('connected', () => {
89
95
  console.log(`[${this.tmuxSession}] Connected to relay`);
90
96
  this.startScreenCapture();
97
+ this.startMetaTracking();
91
98
  });
92
99
  this.wsClient.on('disconnected', ({ code, reason }) => {
93
100
  console.log(`[${this.tmuxSession}] Disconnected: code=${code}, reason=${reason}`);
@@ -127,6 +134,9 @@ class TmuxSessionHandler {
127
134
  this.wsClient.on('uploadFile', (chunk) => {
128
135
  this.handleUploadChunk(chunk);
129
136
  });
137
+ this.wsClient.on('createWorktree', (branch) => {
138
+ this.handleCreateWorktree(branch);
139
+ });
130
140
  this.wsClient.on('error', (error) => {
131
141
  (0, sentry_1.captureException)(error);
132
142
  console.error(`[${this.tmuxSession}] WebSocket error:`, error.message);
@@ -294,6 +304,78 @@ class TmuxSessionHandler {
294
304
  };
295
305
  return types[ext] || 'application/octet-stream';
296
306
  }
307
+ handleCreateWorktree(branch) {
308
+ try {
309
+ const cwd = tmux.getPaneCwd(this.tmuxSession);
310
+ if (!cwd) {
311
+ console.error(`[${this.tmuxSession}] Cannot create worktree: no cwd`);
312
+ return;
313
+ }
314
+ // Check if it's a git repo
315
+ try {
316
+ (0, child_process_1.execSync)('git rev-parse --is-inside-work-tree', { cwd, stdio: 'pipe', timeout: 5000 });
317
+ }
318
+ catch {
319
+ console.error(`[${this.tmuxSession}] Cannot create worktree: not a git repo`);
320
+ return;
321
+ }
322
+ // Sanitize branch name
323
+ const safeBranch = branch.replace(/[^a-zA-Z0-9_\-/.]/g, '-');
324
+ const worktreeDir = path.join(cwd, '.worktrees', safeBranch.replace(/\//g, '-'));
325
+ console.log(`[${this.tmuxSession}] Creating worktree: branch=${safeBranch}, dir=${worktreeDir}`);
326
+ // git worktree add
327
+ (0, child_process_1.execSync)(`git worktree add -b "${safeBranch}" "${worktreeDir}"`, {
328
+ cwd, stdio: 'pipe', timeout: 10000
329
+ });
330
+ // Create new tmux session at worktree path
331
+ const sessionName = safeBranch.replace(/\//g, '-');
332
+ if (this.onCreateSession) {
333
+ this.onCreateSession(sessionName, worktreeDir);
334
+ }
335
+ console.log(`[${this.tmuxSession}] Worktree created: ${worktreeDir}`);
336
+ (0, debug_1.debugLog)(this.tmuxSession, 'Worktree created', { branch: safeBranch, dir: worktreeDir, session: sessionName });
337
+ }
338
+ catch (error) {
339
+ console.error(`[${this.tmuxSession}] Worktree creation failed:`, error.message);
340
+ }
341
+ }
342
+ startMetaTracking() {
343
+ this.checkAndSendMeta();
344
+ this.metaCheckTimer = setInterval(() => this.checkAndSendMeta(), META_CHECK_INTERVAL_MS);
345
+ }
346
+ stopMetaTracking() {
347
+ if (this.metaCheckTimer) {
348
+ clearInterval(this.metaCheckTimer);
349
+ this.metaCheckTimer = null;
350
+ }
351
+ }
352
+ checkAndSendMeta() {
353
+ try {
354
+ const cwd = tmux.getPaneCwd(this.tmuxSession);
355
+ if (!cwd)
356
+ return;
357
+ const gitInfo = tmux.getGitInfo(cwd);
358
+ const meta = { cwd };
359
+ if (gitInfo?.branch)
360
+ meta.gitBranch = gitInfo.branch;
361
+ if (gitInfo?.remote)
362
+ meta.gitRemote = gitInfo.remote;
363
+ if (gitInfo?.repo)
364
+ meta.gitRepo = gitInfo.repo;
365
+ const json = JSON.stringify(meta);
366
+ (0, debug_1.debugLog)(this.tmuxSession, 'Meta check:', { cwd, gitInfo });
367
+ const changed = json !== this.currentMetaJson;
368
+ (0, debug_1.debugLog)(this.tmuxSession, changed ? 'Meta CHANGED, sending' : 'Meta unchanged, skip');
369
+ if (changed) {
370
+ this.currentMetaJson = json;
371
+ this.wsClient?.sendSessionMeta(meta);
372
+ console.log(`[${this.tmuxSession}] Session meta updated: ${json}`);
373
+ }
374
+ }
375
+ catch (error) {
376
+ // Silently ignore meta check errors
377
+ }
378
+ }
297
379
  handleKeys(keys, paneId) {
298
380
  const target = paneId || this.tmuxSession;
299
381
  tmux.sendKeys(target, keys, false);
@@ -405,6 +487,7 @@ class TmuxSessionHandler {
405
487
  console.log(`[${this.tmuxSession}] Stopping`);
406
488
  this.running = false;
407
489
  this.stopScreenCapture();
490
+ this.stopMetaTracking();
408
491
  if (this.wsClient) {
409
492
  this.wsClient.destroy();
410
493
  this.wsClient = null;
@@ -56,4 +56,17 @@ export declare function listPanes(sessionName: string): PaneData[] | null;
56
56
  * Capture a specific pane by its pane ID (e.g., %0, %1)
57
57
  */
58
58
  export declare function capturePaneById(sessionName: string, paneId: string): string | null;
59
+ /**
60
+ * Git info for a directory
61
+ */
62
+ export interface GitInfo {
63
+ branch: string | null;
64
+ remote: string | null;
65
+ repo: string | null;
66
+ }
67
+ /**
68
+ * Detect git info (branch, remote, repo) for a given directory.
69
+ * Returns null if the directory is not inside a git work tree.
70
+ */
71
+ export declare function getGitInfo(cwd: string): GitInfo | null;
59
72
  export type { PaneData } from './tmux-executor';
@@ -14,7 +14,10 @@ exports.getActivePane = getActivePane;
14
14
  exports.getPaneCwd = getPaneCwd;
15
15
  exports.listPanes = listPanes;
16
16
  exports.capturePaneById = capturePaneById;
17
+ exports.getGitInfo = getGitInfo;
17
18
  const tmux_executor_1 = require("./tmux-executor");
19
+ const child_process_1 = require("child_process");
20
+ const debug_1 = require("./debug");
18
21
  // Lazy-initialized executor (created on first use)
19
22
  let executor = null;
20
23
  /**
@@ -171,3 +174,30 @@ function listPanes(sessionName) {
171
174
  function capturePaneById(sessionName, paneId) {
172
175
  return getExecutor().capturePaneById(sessionName, paneId);
173
176
  }
177
+ /**
178
+ * Detect git info (branch, remote, repo) for a given directory.
179
+ * Returns null if the directory is not inside a git work tree.
180
+ */
181
+ function getGitInfo(cwd) {
182
+ try {
183
+ (0, child_process_1.execSync)('git rev-parse --is-inside-work-tree', { cwd, stdio: 'pipe', timeout: 3000 });
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ const run = (cmd) => {
189
+ try {
190
+ return (0, child_process_1.execSync)(cmd, { cwd, encoding: 'utf-8', stdio: 'pipe', timeout: 3000 }).trim();
191
+ }
192
+ catch {
193
+ return null;
194
+ }
195
+ };
196
+ const branch = run('git rev-parse --abbrev-ref HEAD');
197
+ const remote = run('git config --get remote.origin.url');
198
+ const repo = remote
199
+ ? remote.replace(/.*github\.com[:/]/, '').replace(/\.git$/, '')
200
+ : null;
201
+ (0, debug_1.debugLog)('git', `cwd=${cwd}, branch=${branch}, remote=${remote}, repo=${repo}`);
202
+ return { branch, remote, repo };
203
+ }
@@ -9,6 +9,21 @@ export interface ApiConfig {
9
9
  agentId?: string;
10
10
  exec?: ExecConfig;
11
11
  llm?: LlmConfig;
12
+ capabilities?: CapabilitiesConfig;
13
+ }
14
+ /**
15
+ * Per-capability consent config for the CLI agent.
16
+ * Each capability can be:
17
+ * true — auto-grant when SDK requests it
18
+ * false — auto-deny
19
+ * 'ask' — deny with a log notice (future: interactive prompt)
20
+ */
21
+ export interface CapabilitiesConfig {
22
+ exec?: boolean | 'ask';
23
+ exec_cwd?: boolean | 'ask';
24
+ llm_chat?: boolean | 'ask';
25
+ send_keys?: boolean | 'ask';
26
+ list_sessions?: boolean | 'ask';
12
27
  }
13
28
  export interface ExecConfig {
14
29
  enabled: boolean;
@@ -37,6 +52,12 @@ export interface TmuxSession {
37
52
  created?: string;
38
53
  attached: boolean;
39
54
  }
55
+ export interface SessionMetadata {
56
+ cwd: string | null;
57
+ gitBranch: string | null;
58
+ gitRemote: string | null;
59
+ gitRepo: string | null;
60
+ }
40
61
  export interface ExecResult {
41
62
  exitCode: number;
42
63
  stdout: string;
@@ -33,6 +33,7 @@ export declare class RelayWebSocketClient extends EventEmitter {
33
33
  sendScreenWithMeta(data: Buffer, meta: Record<string, string>): boolean;
34
34
  sendScreenCompressed(data: Buffer): boolean;
35
35
  sendScreenCompressedWithMeta(data: Buffer, meta: Record<string, string>): boolean;
36
+ sendSessionMeta(meta: Record<string, string>): boolean;
36
37
  sendPaneLayout(panes: Array<{
37
38
  id: string;
38
39
  index: number;
@@ -40,6 +40,7 @@ exports.RelayWebSocketClient = void 0;
40
40
  const ws_1 = __importDefault(require("ws"));
41
41
  const events_1 = require("events");
42
42
  const zlib = __importStar(require("zlib"));
43
+ const debug_1 = require("./debug");
43
44
  const MAX_RECONNECT_ATTEMPTS = 5;
44
45
  const BASE_RECONNECT_DELAY_MS = 2000;
45
46
  const MAX_RECONNECT_DELAY_MS = 60000;
@@ -116,6 +117,7 @@ class RelayWebSocketClient extends events_1.EventEmitter {
116
117
  });
117
118
  }
118
119
  handleMessage(message) {
120
+ (0, debug_1.debugLog)('WS:IN', message.type, message.session);
119
121
  switch (message.type) {
120
122
  case 'keys':
121
123
  if (message.session === this.sessionId && message.payload) {
@@ -141,6 +143,11 @@ class RelayWebSocketClient extends events_1.EventEmitter {
141
143
  this.emit('killSession');
142
144
  }
143
145
  break;
146
+ case 'createWorktree':
147
+ if (message.session === this.sessionId && message.meta?.branch) {
148
+ this.emit('createWorktree', message.meta.branch);
149
+ }
150
+ break;
144
151
  case 'requestFileView':
145
152
  if (message.session === this.sessionId && message.meta?.filePath) {
146
153
  this.emit('requestFileView', message.meta.filePath);
@@ -232,6 +239,8 @@ class RelayWebSocketClient extends events_1.EventEmitter {
232
239
  return false;
233
240
  }
234
241
  try {
242
+ (0, debug_1.debugLog)('WS:OUT', message.type, message.session, message.type === 'sessionMeta' ? JSON.stringify(message.meta) :
243
+ message.payload ? `payload=${message.payload.length}bytes` : '');
235
244
  this.ws.send(JSON.stringify(message));
236
245
  return true;
237
246
  }
@@ -291,6 +300,15 @@ class RelayWebSocketClient extends events_1.EventEmitter {
291
300
  return this.sendScreenWithMeta(data, meta);
292
301
  }
293
302
  }
303
+ sendSessionMeta(meta) {
304
+ if (!this.isConnected)
305
+ return false;
306
+ return this.send({
307
+ type: 'sessionMeta',
308
+ session: this.sessionId,
309
+ meta
310
+ });
311
+ }
294
312
  sendPaneLayout(panes) {
295
313
  if (!this.isConnected)
296
314
  return false;
@@ -1,5 +1,6 @@
1
1
  interface AgentOptions {
2
2
  config?: string;
3
+ debug?: boolean;
3
4
  }
4
5
  export declare function startAgent(options: AgentOptions): Promise<void>;
5
6
  export {};
@@ -7,8 +7,13 @@ exports.startAgent = startAgent;
7
7
  const chalk_1 = __importDefault(require("chalk"));
8
8
  const runner_1 = require("../agent/runner");
9
9
  const sentry_1 = require("../sentry");
10
+ const debug_1 = require("../agent/debug");
10
11
  async function startAgent(options) {
11
12
  try {
13
+ if (options.debug) {
14
+ (0, debug_1.setDebug)(true);
15
+ console.log(chalk_1.default.yellow('[DEBUG] Debug mode enabled'));
16
+ }
12
17
  const config = runner_1.AgentRunner.loadConfig(options.config);
13
18
  const runner = new runner_1.AgentRunner(config);
14
19
  await runner.start();
package/dist/index.js CHANGED
@@ -209,6 +209,7 @@ program
209
209
  .command('agent')
210
210
  .description('Start the SessionCast agent')
211
211
  .option('-c, --config <path>', 'Path to config file')
212
+ .option('-d, --debug', 'Enable debug logging')
212
213
  .action(agent_1.startAgent);
213
214
  // Help examples
214
215
  program.on('--help', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sessioncast-cli",
3
- "version": "2.0.9",
3
+ "version": "2.2.0",
4
4
  "description": "SessionCast CLI - Control your agents from anywhere",
5
5
  "main": "dist/index.js",
6
6
  "bin": {