mob-coordinator 0.2.1

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.
@@ -0,0 +1,127 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { getSessionsDir, getScrollbackDir, getGitBranch } from './util/platform.js';
4
+ import { SESSION_MAX_AGE_MS } from '../shared/constants.js';
5
+ function log(...args) {
6
+ const ts = new Date().toISOString().slice(11, 23);
7
+ console.log(`[${ts}] [session-store]`, ...args);
8
+ }
9
+ export class SessionStore {
10
+ save(instance, opts) {
11
+ const data = {
12
+ id: instance.id,
13
+ name: instance.name,
14
+ cwd: instance.cwd,
15
+ gitBranch: instance.gitBranch,
16
+ model: instance.model,
17
+ permissionMode: instance.permissionMode,
18
+ claudeSessionId: instance.claudeSessionId,
19
+ previousInstanceId: instance.previousInstanceId,
20
+ createdAt: instance.createdAt || Date.now(),
21
+ stoppedAt: instance.stoppedAt,
22
+ ticket: instance.ticket,
23
+ subtask: instance.subtask,
24
+ autoResume: opts?.autoResume,
25
+ };
26
+ const filePath = path.join(getSessionsDir(), `${instance.id}.json`);
27
+ try {
28
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
29
+ }
30
+ catch (err) {
31
+ log(`Failed to save session ${instance.id}:`, err);
32
+ }
33
+ }
34
+ loadAll() {
35
+ const dir = getSessionsDir();
36
+ const cutoff = Date.now() - SESSION_MAX_AGE_MS;
37
+ const instances = [];
38
+ let files;
39
+ try {
40
+ files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
41
+ }
42
+ catch {
43
+ return [];
44
+ }
45
+ for (const file of files) {
46
+ try {
47
+ const filePath = path.join(dir, file);
48
+ const raw = fs.readFileSync(filePath, 'utf8');
49
+ const data = JSON.parse(raw);
50
+ // Skip expired sessions
51
+ const lastActive = data.stoppedAt || data.createdAt;
52
+ if (lastActive < cutoff) {
53
+ this.removeFiles(data.id);
54
+ continue;
55
+ }
56
+ instances.push({
57
+ id: data.id,
58
+ name: data.name,
59
+ managed: true,
60
+ cwd: data.cwd,
61
+ gitBranch: getGitBranch(data.cwd) || data.gitBranch,
62
+ state: 'stopped',
63
+ lastUpdated: data.stoppedAt || data.createdAt,
64
+ model: data.model,
65
+ permissionMode: data.permissionMode,
66
+ claudeSessionId: data.claudeSessionId,
67
+ previousInstanceId: data.previousInstanceId,
68
+ createdAt: data.createdAt,
69
+ stoppedAt: data.stoppedAt,
70
+ historical: true,
71
+ ticket: data.ticket,
72
+ subtask: data.subtask,
73
+ autoResume: data.autoResume,
74
+ });
75
+ }
76
+ catch (err) {
77
+ log(`Failed to load session from ${file}:`, err);
78
+ }
79
+ }
80
+ log(`Loaded ${instances.length} previous session(s)`);
81
+ return instances;
82
+ }
83
+ pruneExpired() {
84
+ const dir = getSessionsDir();
85
+ const cutoff = Date.now() - SESSION_MAX_AGE_MS;
86
+ let pruned = 0;
87
+ let files;
88
+ try {
89
+ files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
90
+ }
91
+ catch {
92
+ return;
93
+ }
94
+ for (const file of files) {
95
+ try {
96
+ const filePath = path.join(dir, file);
97
+ const raw = fs.readFileSync(filePath, 'utf8');
98
+ const data = JSON.parse(raw);
99
+ const lastActive = data.stoppedAt || data.createdAt;
100
+ if (lastActive < cutoff) {
101
+ this.removeFiles(data.id);
102
+ pruned++;
103
+ }
104
+ }
105
+ catch {
106
+ // Skip malformed files
107
+ }
108
+ }
109
+ if (pruned > 0)
110
+ log(`Pruned ${pruned} expired session(s)`);
111
+ }
112
+ remove(instanceId) {
113
+ this.removeFiles(instanceId);
114
+ }
115
+ removeFiles(instanceId) {
116
+ const sessionPath = path.join(getSessionsDir(), `${instanceId}.json`);
117
+ const scrollbackPath = path.join(getScrollbackDir(), `${instanceId}.log`);
118
+ try {
119
+ fs.unlinkSync(sessionPath);
120
+ }
121
+ catch { /* ok */ }
122
+ try {
123
+ fs.unlinkSync(scrollbackPath);
124
+ }
125
+ catch { /* ok */ }
126
+ }
127
+ }
@@ -0,0 +1,87 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { getMobDir } from './util/platform.js';
4
+ import { DEFAULT_SETTINGS, mergeWithDefaults } from '../shared/settings.js';
5
+ import { createLogger } from './util/logger.js';
6
+ const log = createLogger('settings');
7
+ function clamp(val, min, max) {
8
+ return Math.max(min, Math.min(max, val));
9
+ }
10
+ function validate(settings) {
11
+ settings.terminal.fontSize = clamp(Math.round(settings.terminal.fontSize), 8, 24);
12
+ settings.terminal.scrollbackLines = clamp(Math.round(settings.terminal.scrollbackLines), 100, 100_000);
13
+ settings.general.maxCachedTerminals = clamp(Math.round(settings.general.maxCachedTerminals), 1, 100);
14
+ settings.general.staleThresholdSecs = clamp(Math.round(settings.general.staleThresholdSecs), 5, 300);
15
+ const validCursorStyles = ['block', 'underline', 'bar'];
16
+ if (!validCursorStyles.includes(settings.terminal.cursorStyle)) {
17
+ settings.terminal.cursorStyle = 'block';
18
+ }
19
+ // Strip trailing slashes from JIRA base URL
20
+ if (settings.jira?.baseUrl) {
21
+ settings.jira.baseUrl = settings.jira.baseUrl.replace(/\/+$/, '');
22
+ }
23
+ return settings;
24
+ }
25
+ export class SettingsManager {
26
+ settings;
27
+ filePath;
28
+ constructor() {
29
+ this.filePath = path.join(getMobDir(), 'settings.json');
30
+ this.settings = this.load();
31
+ }
32
+ load() {
33
+ try {
34
+ if (fs.existsSync(this.filePath)) {
35
+ const raw = fs.readFileSync(this.filePath, 'utf-8');
36
+ const parsed = JSON.parse(raw);
37
+ const merged = mergeWithDefaults(parsed);
38
+ return validate(merged);
39
+ }
40
+ }
41
+ catch (err) {
42
+ log.error('Failed to load settings, using defaults:', err);
43
+ }
44
+ return structuredClone(DEFAULT_SETTINGS);
45
+ }
46
+ save() {
47
+ const tmpPath = this.filePath + '.tmp';
48
+ try {
49
+ fs.writeFileSync(tmpPath, JSON.stringify(this.settings, null, 2), 'utf-8');
50
+ fs.renameSync(tmpPath, this.filePath);
51
+ }
52
+ catch (err) {
53
+ log.error('Failed to save settings:', err);
54
+ // Clean up tmp file if rename failed
55
+ try {
56
+ fs.unlinkSync(tmpPath);
57
+ }
58
+ catch { }
59
+ }
60
+ }
61
+ get() {
62
+ return structuredClone(this.settings);
63
+ }
64
+ getRedacted() {
65
+ const copy = structuredClone(this.settings);
66
+ if (copy.jira) {
67
+ copy.jira.apiToken = this.settings.jira?.apiToken ? '••••' : '';
68
+ }
69
+ return copy;
70
+ }
71
+ update(partial) {
72
+ // If jira.apiToken comes in as the redacted sentinel, preserve existing token
73
+ if (partial.jira && partial.jira.apiToken === '••••') {
74
+ partial.jira.apiToken = this.settings.jira.apiToken;
75
+ }
76
+ const merged = mergeWithDefaults({ ...this.settings, ...partial });
77
+ // Deep merge: for each section in partial, overlay onto current
78
+ for (const section of Object.keys(partial)) {
79
+ if (typeof partial[section] === 'object' && section in merged) {
80
+ Object.assign(merged[section], partial[section]);
81
+ }
82
+ }
83
+ this.settings = validate(merged);
84
+ this.save();
85
+ return this.get();
86
+ }
87
+ }
@@ -0,0 +1,29 @@
1
+ import fs from 'fs';
2
+ export function readStatusFile(filePath) {
3
+ try {
4
+ const raw = fs.readFileSync(filePath, 'utf-8');
5
+ const data = JSON.parse(raw);
6
+ if (!data.id || !data.cwd || !data.lastUpdated) {
7
+ return null;
8
+ }
9
+ return {
10
+ id: data.id,
11
+ cwd: data.cwd,
12
+ gitBranch: data.gitBranch || undefined,
13
+ state: data.state || 'running',
14
+ ticket: data.ticket || undefined,
15
+ ticketStatus: data.ticketStatus || undefined,
16
+ subtask: data.subtask || undefined,
17
+ progress: typeof data.progress === 'number' ? data.progress : undefined,
18
+ currentTool: data.currentTool || undefined,
19
+ lastUpdated: data.lastUpdated,
20
+ sessionId: data.sessionId || undefined,
21
+ model: data.model || undefined,
22
+ topic: data.topic || undefined,
23
+ hookEvent: data.hookEvent || undefined,
24
+ };
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
@@ -0,0 +1,42 @@
1
+ // Braille spinner characters used by Claude CLI
2
+ const SPINNER_CHARS = '⠋⠙⠚⠞⠖⠦⠴⠵⠸⠇⠏';
3
+ // Patterns checked in priority order: waiting > running > idle
4
+ const WAITING_PATTERNS = [
5
+ /\(y\/n\)/i,
6
+ /\[y\/n\]/i,
7
+ /\bapprove\b/i,
8
+ /\ballow\b/i,
9
+ /Do you want to proceed/i,
10
+ /\byes\b.*\bno\b/i,
11
+ /\baccept\b/i,
12
+ /\bdeny\b/i,
13
+ ];
14
+ const RUNNING_PATTERNS = [
15
+ /esc to interrupt/i,
16
+ new RegExp(`[${SPINNER_CHARS}]`),
17
+ ];
18
+ // Idle: bare prompt at end of output
19
+ const IDLE_PATTERNS = [
20
+ />\s*$/, // Claude's `> ` prompt
21
+ /\$\s*$/, // Shell `$ ` prompt
22
+ ];
23
+ export function detectStateFromTerminal(scrollbackTail) {
24
+ if (!scrollbackTail || scrollbackTail.length === 0)
25
+ return null;
26
+ // Check waiting patterns first (highest priority)
27
+ for (const pattern of WAITING_PATTERNS) {
28
+ if (pattern.test(scrollbackTail))
29
+ return 'waiting';
30
+ }
31
+ // Check running patterns
32
+ for (const pattern of RUNNING_PATTERNS) {
33
+ if (pattern.test(scrollbackTail))
34
+ return 'running';
35
+ }
36
+ // Check idle patterns (only match end of output)
37
+ for (const pattern of IDLE_PATTERNS) {
38
+ if (pattern.test(scrollbackTail))
39
+ return 'idle';
40
+ }
41
+ return null;
42
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ import crypto from 'crypto';
2
+ export function generateInstanceId() {
3
+ return `mob-${crypto.randomUUID()}`;
4
+ }
@@ -0,0 +1,34 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ const logFile = path.join(os.homedir(), '.mob', 'server.log');
5
+ function appendToFile(line) {
6
+ try {
7
+ fs.appendFileSync(logFile, line + '\n');
8
+ }
9
+ catch { }
10
+ }
11
+ export function createLogger(module) {
12
+ function format(...args) {
13
+ const ts = new Date().toISOString().slice(11, 23);
14
+ return [`[${ts}] [${module}]`, ...args];
15
+ }
16
+ function formatString(...args) {
17
+ const ts = new Date().toISOString().slice(11, 23);
18
+ return `[${ts}] [${module}] ${args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')}`;
19
+ }
20
+ return {
21
+ info(...args) {
22
+ console.log(...format(...args));
23
+ appendToFile(formatString(...args));
24
+ },
25
+ warn(...args) {
26
+ console.warn(...format(...args));
27
+ appendToFile('WARN ' + formatString(...args));
28
+ },
29
+ error(...args) {
30
+ console.error(...format(...args));
31
+ appendToFile('ERROR ' + formatString(...args));
32
+ },
33
+ };
34
+ }
@@ -0,0 +1,48 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import { execFileSync } from 'child_process';
5
+ export function getDefaultShell() {
6
+ if (process.platform === 'win32') {
7
+ return process.env.COMSPEC || 'powershell.exe';
8
+ }
9
+ return process.env.SHELL || '/bin/bash';
10
+ }
11
+ export function getShellArgs(shell) {
12
+ if (shell.includes('powershell') || shell.includes('pwsh')) {
13
+ return ['-NoLogo'];
14
+ }
15
+ return [];
16
+ }
17
+ export function ensureDir(dir) {
18
+ fs.mkdirSync(dir, { recursive: true });
19
+ }
20
+ export function getMobDir() {
21
+ return path.join(os.homedir(), '.mob');
22
+ }
23
+ export function getInstancesDir() {
24
+ return path.join(getMobDir(), 'instances');
25
+ }
26
+ export function getSessionsDir() {
27
+ return path.join(getMobDir(), 'sessions');
28
+ }
29
+ export function getScrollbackDir() {
30
+ return path.join(getMobDir(), 'scrollback');
31
+ }
32
+ export function getGitBranch(cwd) {
33
+ try {
34
+ let resolved = cwd;
35
+ if (resolved.startsWith('~/') || resolved === '~') {
36
+ resolved = os.homedir() + resolved.slice(1);
37
+ }
38
+ return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
39
+ cwd: resolved,
40
+ encoding: 'utf-8',
41
+ timeout: 3000,
42
+ stdio: ['ignore', 'pipe', 'ignore'],
43
+ }).trim() || undefined;
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ }
@@ -0,0 +1,126 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+ // --- Shell quoting ---
4
+ /** Wrap a value in single quotes with proper escaping for safe shell interpolation. */
5
+ export function shellQuote(s) {
6
+ return "'" + s.replace(/'/g, "'\\''") + "'";
7
+ }
8
+ // --- Field validators ---
9
+ const MODEL_PATTERN = /^[a-zA-Z0-9._:/-]+$/;
10
+ const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
11
+ const INSTANCE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
12
+ const VALID_PERMISSION_MODES = ['default', 'plan', 'bypassPermissions', 'full'];
13
+ export function isValidModel(s) {
14
+ return MODEL_PATTERN.test(s) && s.length <= 100;
15
+ }
16
+ export function isValidPermissionMode(s) {
17
+ return VALID_PERMISSION_MODES.includes(s);
18
+ }
19
+ export function isValidSessionId(s) {
20
+ return SESSION_ID_PATTERN.test(s) && s.length <= 200;
21
+ }
22
+ export function isValidInstanceId(s) {
23
+ return INSTANCE_ID_PATTERN.test(s) && s.length <= 100;
24
+ }
25
+ export function isValidCwd(s) {
26
+ if (!s || s.length > 1024)
27
+ return false;
28
+ if (s.includes('\0'))
29
+ return false;
30
+ // Must be absolute path or start with ~
31
+ return s.startsWith('/') || s.startsWith('~');
32
+ }
33
+ export function isValidName(s) {
34
+ return s.length <= 256;
35
+ }
36
+ /** Strip control characters (except newline/tab) from a string. */
37
+ export function stripControlChars(s) {
38
+ // eslint-disable-next-line no-control-regex
39
+ return s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
40
+ }
41
+ // --- Path safety ---
42
+ /** Check if a resolved path is within the user's home directory. */
43
+ export function isPathWithinHome(resolved) {
44
+ const home = os.homedir();
45
+ const normalized = path.resolve(resolved);
46
+ return normalized === home || normalized.startsWith(home + path.sep);
47
+ }
48
+ export function validateLaunchPayload(payload) {
49
+ if (!payload || typeof payload !== 'object') {
50
+ return { valid: false, error: 'Payload must be an object' };
51
+ }
52
+ const p = payload;
53
+ // cwd: required, valid path
54
+ if (typeof p.cwd !== 'string' || !isValidCwd(p.cwd)) {
55
+ return { valid: false, error: 'Invalid or missing cwd (must be absolute path or start with ~, max 1024 chars)' };
56
+ }
57
+ // name: optional string, max 256 chars
58
+ const name = typeof p.name === 'string' ? stripControlChars(p.name).slice(0, 256) : '';
59
+ // model: optional, must match pattern
60
+ if (p.model !== undefined && p.model !== '') {
61
+ if (typeof p.model !== 'string' || !isValidModel(p.model)) {
62
+ return { valid: false, error: 'Invalid model (alphanumeric, dots, colons, hyphens, slashes only)' };
63
+ }
64
+ }
65
+ // permissionMode: optional, must be in allowlist
66
+ if (p.permissionMode !== undefined && p.permissionMode !== '') {
67
+ if (typeof p.permissionMode !== 'string' || !isValidPermissionMode(p.permissionMode)) {
68
+ return { valid: false, error: `Invalid permissionMode (must be one of: ${VALID_PERMISSION_MODES.join(', ')})` };
69
+ }
70
+ }
71
+ return {
72
+ valid: true,
73
+ data: {
74
+ name,
75
+ autoName: !!p.autoName,
76
+ cwd: p.cwd,
77
+ model: (typeof p.model === 'string' && p.model) || undefined,
78
+ permissionMode: (typeof p.permissionMode === 'string' && p.permissionMode) || undefined,
79
+ },
80
+ };
81
+ }
82
+ export function validateHookPayload(data) {
83
+ if (!data || typeof data !== 'object') {
84
+ return { valid: false, error: 'Payload must be an object' };
85
+ }
86
+ const d = data;
87
+ // id: required
88
+ if (typeof d.id !== 'string' || !d.id) {
89
+ return { valid: false, error: 'Missing instance id' };
90
+ }
91
+ if (d.id.length > 200) {
92
+ return { valid: false, error: 'Instance id too long' };
93
+ }
94
+ // cwd: optional but if present must be valid
95
+ if (d.cwd !== undefined && typeof d.cwd === 'string') {
96
+ if (d.cwd.includes('\0') || d.cwd.length > 1024) {
97
+ return { valid: false, error: 'Invalid cwd' };
98
+ }
99
+ }
100
+ // sessionId: optional, alphanumeric
101
+ if (d.sessionId !== undefined && typeof d.sessionId === 'string') {
102
+ if (!isValidSessionId(d.sessionId)) {
103
+ return { valid: false, error: 'Invalid sessionId format' };
104
+ }
105
+ }
106
+ // model: optional
107
+ if (d.model !== undefined && typeof d.model === 'string' && d.model !== '') {
108
+ if (!isValidModel(d.model)) {
109
+ return { valid: false, error: 'Invalid model format' };
110
+ }
111
+ }
112
+ // Strip control chars from string fields
113
+ if (typeof d.name === 'string')
114
+ d.name = stripControlChars(d.name).slice(0, 256);
115
+ if (typeof d.subtask === 'string')
116
+ d.subtask = stripControlChars(d.subtask).slice(0, 500);
117
+ if (typeof d.topic === 'string')
118
+ d.topic = stripControlChars(d.topic).slice(0, 500);
119
+ if (typeof d.ticket === 'string')
120
+ d.ticket = stripControlChars(d.ticket).slice(0, 200);
121
+ if (typeof d.ticketStatus === 'string')
122
+ d.ticketStatus = stripControlChars(d.ticketStatus).slice(0, 100);
123
+ if (typeof d.hookEvent === 'string')
124
+ d.hookEvent = stripControlChars(d.hookEvent).slice(0, 50);
125
+ return { valid: true, data: d };
126
+ }