skimpyclaw 0.3.6 → 0.3.9

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.
Files changed (73) hide show
  1. package/README.md +14 -6
  2. package/dist/__tests__/api.test.js +1 -0
  3. package/dist/__tests__/channels.test.js +1 -1
  4. package/dist/__tests__/code-agents-orchestrator.test.js +74 -7
  5. package/dist/__tests__/code-agents-preflight.test.d.ts +1 -0
  6. package/dist/__tests__/code-agents-preflight.test.js +88 -0
  7. package/dist/__tests__/code-agents-sandbox.test.d.ts +1 -0
  8. package/dist/__tests__/code-agents-sandbox.test.js +163 -0
  9. package/dist/__tests__/code-agents-utils.test.js +12 -1
  10. package/dist/__tests__/context-manager.test.d.ts +1 -0
  11. package/dist/__tests__/context-manager.test.js +236 -0
  12. package/dist/__tests__/package-manager-detection.test.js +5 -5
  13. package/dist/__tests__/setup.test.js +7 -5
  14. package/dist/__tests__/skills.test.js +2 -2
  15. package/dist/__tests__/structured-context.test.d.ts +1 -0
  16. package/dist/__tests__/structured-context.test.js +100 -0
  17. package/dist/__tests__/tools.test.js +65 -3
  18. package/dist/agent.js +4 -5
  19. package/dist/api.js +10 -58
  20. package/dist/audit.js +5 -51
  21. package/dist/channels/telegram/handlers.js +2 -60
  22. package/dist/channels/telegram/index.js +0 -7
  23. package/dist/channels.js +1 -1
  24. package/dist/cli.js +151 -16
  25. package/dist/code-agents/executor.d.ts +9 -4
  26. package/dist/code-agents/executor.js +187 -13
  27. package/dist/code-agents/index.d.ts +1 -1
  28. package/dist/code-agents/index.js +30 -22
  29. package/dist/code-agents/orchestrator.d.ts +8 -2
  30. package/dist/code-agents/orchestrator.js +318 -27
  31. package/dist/code-agents/structured-context.d.ts +7 -0
  32. package/dist/code-agents/structured-context.js +54 -0
  33. package/dist/code-agents/types.d.ts +2 -0
  34. package/dist/code-agents/utils.d.ts +4 -0
  35. package/dist/code-agents/utils.js +38 -2
  36. package/dist/code-agents/worktree.d.ts +40 -0
  37. package/dist/code-agents/worktree.js +215 -0
  38. package/dist/config.d.ts +1 -0
  39. package/dist/config.js +5 -3
  40. package/dist/cron.js +18 -4
  41. package/dist/dashboard/assets/{index-CkonC7Cd.js → index-BoTHPby4.js} +20 -20
  42. package/dist/dashboard/assets/{index-EAg6lqF5.css → index-D4mufvBg.css} +1 -1
  43. package/dist/dashboard/index.html +2 -2
  44. package/dist/discord.js +4 -40
  45. package/dist/exec-approval.js +1 -1
  46. package/dist/file-lock.js +1 -1
  47. package/dist/gateway.js +3 -10
  48. package/dist/providers/anthropic.js +9 -5
  49. package/dist/providers/codex.js +10 -6
  50. package/dist/providers/context-manager.d.ts +22 -0
  51. package/dist/providers/context-manager.js +100 -0
  52. package/dist/providers/openai.js +9 -5
  53. package/dist/providers/types.d.ts +1 -0
  54. package/dist/security.js +9 -0
  55. package/dist/setup.js +122 -27
  56. package/dist/skills.js +9 -2
  57. package/dist/subagent.js +33 -2
  58. package/dist/tools/bash-tool.js +8 -0
  59. package/dist/tools/browser-tool.js +2 -1
  60. package/dist/tools/definitions.d.ts +0 -27
  61. package/dist/tools/definitions.js +0 -18
  62. package/dist/tools/execute-context.d.ts +4 -4
  63. package/dist/tools/file-tools.d.ts +1 -1
  64. package/dist/tools/file-tools.js +1 -1
  65. package/dist/tools.d.ts +5 -5
  66. package/dist/tools.js +87 -98
  67. package/dist/types.d.ts +14 -22
  68. package/dist/usage.d.ts +1 -0
  69. package/dist/usage.js +30 -46
  70. package/dist/utils.d.ts +18 -0
  71. package/dist/utils.js +71 -0
  72. package/dist/voice.js +9 -7
  73. package/package.json +26 -21
@@ -0,0 +1,215 @@
1
+ // Git worktree isolation for parallel coding agents.
2
+ // Each parallel child gets its own worktree so they can't overwrite each other.
3
+ import { execSync } from 'child_process';
4
+ import { existsSync, rmSync } from 'fs';
5
+ import { join } from 'path';
6
+ /**
7
+ * Check if a directory is inside a git repo.
8
+ */
9
+ export function isGitRepo(workdir) {
10
+ try {
11
+ execSync('git rev-parse --is-inside-work-tree', {
12
+ cwd: workdir,
13
+ timeout: 5000,
14
+ encoding: 'utf-8',
15
+ stdio: ['ignore', 'pipe', 'pipe'],
16
+ });
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ /**
24
+ * Get the git repo root directory.
25
+ */
26
+ export function getGitRoot(workdir) {
27
+ return execSync('git rev-parse --show-toplevel', {
28
+ cwd: workdir,
29
+ timeout: 5000,
30
+ encoding: 'utf-8',
31
+ stdio: ['ignore', 'pipe', 'pipe'],
32
+ }).trim();
33
+ }
34
+ /**
35
+ * Stage and commit all current changes so worktrees branch from a clean state.
36
+ * Returns the commit hash, or null if nothing to commit.
37
+ */
38
+ export function commitPendingChanges(workdir, message) {
39
+ try {
40
+ // Check for any changes (staged or unstaged)
41
+ const status = execSync('git status --porcelain', {
42
+ cwd: workdir,
43
+ timeout: 5000,
44
+ encoding: 'utf-8',
45
+ stdio: ['ignore', 'pipe', 'pipe'],
46
+ }).trim();
47
+ if (!status)
48
+ return null;
49
+ execSync('git add -A && git commit -m ' + JSON.stringify(message), {
50
+ cwd: workdir,
51
+ timeout: 10000,
52
+ encoding: 'utf-8',
53
+ stdio: ['ignore', 'pipe', 'pipe'],
54
+ });
55
+ return execSync('git rev-parse HEAD', {
56
+ cwd: workdir,
57
+ timeout: 5000,
58
+ encoding: 'utf-8',
59
+ stdio: ['ignore', 'pipe', 'pipe'],
60
+ }).trim();
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ /**
67
+ * Create a git worktree for a child agent.
68
+ * Creates a new branch and worktree directory under .skimpyclaw-worktrees/.
69
+ */
70
+ export function createWorktree(workdir, childId) {
71
+ const gitRoot = getGitRoot(workdir);
72
+ const worktreeDir = join(gitRoot, '.skimpyclaw-worktrees', childId);
73
+ const branch = `skimpyclaw-team/${childId}`;
74
+ // Clean up stale worktree/branch if they exist
75
+ try {
76
+ execSync(`git worktree remove --force ${JSON.stringify(worktreeDir)}`, {
77
+ cwd: gitRoot,
78
+ timeout: 10000,
79
+ encoding: 'utf-8',
80
+ stdio: ['ignore', 'pipe', 'pipe'],
81
+ });
82
+ }
83
+ catch { /* didn't exist */ }
84
+ try {
85
+ execSync(`git branch -D ${JSON.stringify(branch)}`, {
86
+ cwd: gitRoot,
87
+ timeout: 5000,
88
+ encoding: 'utf-8',
89
+ stdio: ['ignore', 'pipe', 'pipe'],
90
+ });
91
+ }
92
+ catch { /* didn't exist */ }
93
+ // Create worktree on a new branch from HEAD
94
+ execSync(`git worktree add -b ${JSON.stringify(branch)} ${JSON.stringify(worktreeDir)} HEAD`, {
95
+ cwd: gitRoot,
96
+ timeout: 15000,
97
+ encoding: 'utf-8',
98
+ stdio: ['ignore', 'pipe', 'pipe'],
99
+ });
100
+ return { path: worktreeDir, branch };
101
+ }
102
+ /**
103
+ * Merge a child's worktree branch back into the current branch.
104
+ * Returns { merged: true } on success, { merged: false, conflict: string } on conflict.
105
+ */
106
+ export function mergeWorktree(workdir, branch, childId) {
107
+ const gitRoot = getGitRoot(workdir);
108
+ try {
109
+ // First check if the child branch has any changes compared to where it branched
110
+ const diff = execSync(`git diff HEAD...${JSON.stringify(branch)} --stat`, {
111
+ cwd: gitRoot,
112
+ timeout: 10000,
113
+ encoding: 'utf-8',
114
+ stdio: ['ignore', 'pipe', 'pipe'],
115
+ }).trim();
116
+ if (!diff) {
117
+ // No changes on child branch — nothing to merge
118
+ return { merged: true };
119
+ }
120
+ execSync(`git merge --no-edit ${JSON.stringify(branch)}`, {
121
+ cwd: gitRoot,
122
+ timeout: 15000,
123
+ encoding: 'utf-8',
124
+ stdio: ['ignore', 'pipe', 'pipe'],
125
+ });
126
+ return { merged: true };
127
+ }
128
+ catch (err) {
129
+ // Merge conflict — abort and report
130
+ try {
131
+ execSync('git merge --abort', {
132
+ cwd: gitRoot,
133
+ timeout: 5000,
134
+ encoding: 'utf-8',
135
+ stdio: ['ignore', 'pipe', 'pipe'],
136
+ });
137
+ }
138
+ catch { /* no merge in progress */ }
139
+ const conflict = err instanceof Error ? err.message : String(err);
140
+ return { merged: false, conflict: conflict.slice(0, 2000) };
141
+ }
142
+ }
143
+ /**
144
+ * Remove a worktree and its branch.
145
+ */
146
+ export function removeWorktree(workdir, childId, branch) {
147
+ const gitRoot = getGitRoot(workdir);
148
+ const worktreeDir = join(gitRoot, '.skimpyclaw-worktrees', childId);
149
+ try {
150
+ execSync(`git worktree remove --force ${JSON.stringify(worktreeDir)}`, {
151
+ cwd: gitRoot,
152
+ timeout: 10000,
153
+ encoding: 'utf-8',
154
+ stdio: ['ignore', 'pipe', 'pipe'],
155
+ });
156
+ }
157
+ catch { /* already removed */ }
158
+ // Clean up directory if git worktree remove didn't
159
+ if (existsSync(worktreeDir)) {
160
+ rmSync(worktreeDir, { recursive: true, force: true });
161
+ }
162
+ try {
163
+ execSync(`git branch -D ${JSON.stringify(branch)}`, {
164
+ cwd: gitRoot,
165
+ timeout: 5000,
166
+ encoding: 'utf-8',
167
+ stdio: ['ignore', 'pipe', 'pipe'],
168
+ });
169
+ }
170
+ catch { /* already deleted */ }
171
+ }
172
+ /**
173
+ * Clean up all skimpyclaw worktrees in a repo (e.g. on crash recovery).
174
+ */
175
+ export function cleanupAllWorktrees(workdir) {
176
+ const gitRoot = getGitRoot(workdir);
177
+ const worktreeBaseDir = join(gitRoot, '.skimpyclaw-worktrees');
178
+ // Prune stale worktree references
179
+ try {
180
+ execSync('git worktree prune', {
181
+ cwd: gitRoot,
182
+ timeout: 10000,
183
+ encoding: 'utf-8',
184
+ stdio: ['ignore', 'pipe', 'pipe'],
185
+ });
186
+ }
187
+ catch { /* ignore */ }
188
+ // Remove the directory
189
+ if (existsSync(worktreeBaseDir)) {
190
+ rmSync(worktreeBaseDir, { recursive: true, force: true });
191
+ }
192
+ // Delete all skimpyclaw-team/* branches
193
+ try {
194
+ const branches = execSync('git branch --list "skimpyclaw-team/*"', {
195
+ cwd: gitRoot,
196
+ timeout: 5000,
197
+ encoding: 'utf-8',
198
+ stdio: ['ignore', 'pipe', 'pipe'],
199
+ }).trim();
200
+ if (branches) {
201
+ for (const b of branches.split('\n').map(s => s.trim()).filter(Boolean)) {
202
+ try {
203
+ execSync(`git branch -D ${JSON.stringify(b)}`, {
204
+ cwd: gitRoot,
205
+ timeout: 5000,
206
+ encoding: 'utf-8',
207
+ stdio: ['ignore', 'pipe', 'pipe'],
208
+ });
209
+ }
210
+ catch { /* ignore */ }
211
+ }
212
+ }
213
+ }
214
+ catch { /* ignore */ }
215
+ }
package/dist/config.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { Config } from './types.js';
2
2
  export declare function loadConfig(): Config;
3
3
  export declare function loadRawConfig(): Record<string, any>;
4
4
  export declare function getConfigPath(): string;
5
+ export declare function isValidAgentId(agentId: string): boolean;
5
6
  export declare function getAgentDir(agentId: string): string;
6
7
  export declare function getLogsDir(): string;
7
8
  export declare function getSessionsDir(): string;
package/dist/config.js CHANGED
@@ -54,8 +54,11 @@ export function loadRawConfig() {
54
54
  export function getConfigPath() {
55
55
  return CONFIG_PATH;
56
56
  }
57
+ export function isValidAgentId(agentId) {
58
+ return /^[a-zA-Z0-9_-]+$/.test(agentId);
59
+ }
57
60
  export function getAgentDir(agentId) {
58
- if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) {
61
+ if (!isValidAgentId(agentId)) {
59
62
  throw new Error('Invalid agent ID');
60
63
  }
61
64
  return join(homedir(), '.skimpyclaw', 'agents', agentId);
@@ -107,8 +110,7 @@ export function listMemoryFiles(agentId) {
107
110
  }).sort((a, b) => b.date.localeCompare(a.date));
108
111
  }
109
112
  export function readMemoryFile(agentId, filename) {
110
- // Validate agentId is a safe identifier
111
- if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) {
113
+ if (!isValidAgentId(agentId)) {
112
114
  throw new Error('Invalid agent ID');
113
115
  }
114
116
  // Validate no path traversal
package/dist/cron.js CHANGED
@@ -10,6 +10,20 @@ import { startTrace, addEvent, endTrace } from './audit.js';
10
10
  import { sendActiveChannelProactiveMessage, sendActiveChannelProactiveVoice, getActiveChannelId } from './channels.js';
11
11
  import { parseAndSaveDigest } from './digests.js';
12
12
  import { synthesizeSpeech } from './voice.js';
13
+ import { toErrorMessage } from './utils.js';
14
+ function safeTimezone(tz) {
15
+ const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
16
+ if (!tz)
17
+ return fallback;
18
+ try {
19
+ Intl.DateTimeFormat(undefined, { timeZone: tz });
20
+ return tz;
21
+ }
22
+ catch {
23
+ console.warn(`[cron] Invalid timezone "${tz}", falling back to ${fallback}`);
24
+ return fallback;
25
+ }
26
+ }
13
27
  import { ensureContainer, SANDBOX_DEFAULTS, sandboxBash } from './sandbox/index.js';
14
28
  const scheduledJobs = new Map();
15
29
  let configWatcher = null;
@@ -100,7 +114,7 @@ function scheduleJob(jobDef, config) {
100
114
  return;
101
115
  }
102
116
  const cronJob = new Cron(jobDef.schedule.expr, {
103
- timezone: jobDef.schedule.tz || 'America/Chicago',
117
+ timezone: safeTimezone(jobDef.schedule.tz),
104
118
  }, async () => {
105
119
  console.log(`[cron] Running job: ${jobDef.name}`);
106
120
  await executeJobPayload(jobDef, config);
@@ -235,7 +249,7 @@ async function executeJobPayload(jobDef, config) {
235
249
  appendCronLogLine(jobDef.id, `=== COMPLETED: success ===`);
236
250
  }
237
251
  catch (err) {
238
- const msg = err instanceof Error ? err.message : String(err);
252
+ const msg = toErrorMessage(err);
239
253
  logEntry.status = msg.includes('TIMEOUT') || msg.includes('timed out') ? 'timeout' : 'error';
240
254
  logEntry.error = msg;
241
255
  appendCronLogLine(jobDef.id, `=== FAILED: ${logEntry.status} — ${msg.slice(0, 200)} ===`);
@@ -337,14 +351,14 @@ function resolveMessageSource(message) {
337
351
  return message;
338
352
  // Expand ~ to home directory
339
353
  const resolved = trimmed.startsWith('~/')
340
- ? join(process.env.HOME || '', trimmed.slice(2))
354
+ ? join(homedir(), trimmed.slice(2))
341
355
  : trimmed;
342
356
  if (existsSync(resolved)) {
343
357
  console.log(`[cron] Loading prompt from file: ${resolved}`);
344
358
  return readFileSync(resolved, 'utf-8');
345
359
  }
346
360
  // Fallback: check ~/.skimpyclaw/prompts/ directory
347
- const promptsDir = join(process.env.HOME || '', '.skimpyclaw', 'prompts', trimmed);
361
+ const promptsDir = join(homedir(), '.skimpyclaw', 'prompts', trimmed);
348
362
  if (existsSync(promptsDir)) {
349
363
  console.log(`[cron] Loading prompt from prompts dir: ${promptsDir}`);
350
364
  return readFileSync(promptsDir, 'utf-8');