shmakk 1.2.0 → 1.2.2

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.
package/src/ssh.js ADDED
@@ -0,0 +1,255 @@
1
+ // SSH remote execution and file transfer for shmakk.
2
+ //
3
+ // Hosts are defined in .shmakk/hosts.json (project-local) or
4
+ // ~/.config/shmakk/hosts.json (global). If a host config has
5
+ // allow_ssh_config: true, ~/.ssh/config Host entries are also
6
+ // available as targets.
7
+ //
8
+ // Schema (hosts.json):
9
+ // {
10
+ // "hosts": {
11
+ // "devbox": {
12
+ // "host": "user@192.168.1.100",
13
+ // "port": 22,
14
+ // "auto_approve": false,
15
+ // "timeout_sec": 30
16
+ // }
17
+ // },
18
+ // "allow_ssh_config": false,
19
+ // "default_timeout_sec": 30
20
+ // }
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const os = require('os');
25
+ const { execFile } = require('child_process');
26
+
27
+ // ── Config loading ─────────────────────────────────────────────────────
28
+
29
+ function loadHostConfig(workspaceRoot) {
30
+ const candidates = [];
31
+ // Project-local config
32
+ if (workspaceRoot) {
33
+ candidates.push(path.join(workspaceRoot, '.shmakk', 'hosts.json'));
34
+ }
35
+ // Global config
36
+ candidates.push(path.join(os.homedir(), '.config', 'shmakk', 'hosts.json'));
37
+
38
+ let merged = { hosts: {}, allow_ssh_config: false, default_timeout_sec: 30 };
39
+
40
+ for (const p of candidates) {
41
+ try {
42
+ const raw = fs.readFileSync(p, 'utf8');
43
+ const cfg = JSON.parse(raw);
44
+ if (cfg.hosts && typeof cfg.hosts === 'object') {
45
+ Object.assign(merged.hosts, cfg.hosts);
46
+ }
47
+ if (typeof cfg.allow_ssh_config === 'boolean') {
48
+ merged.allow_ssh_config = merged.allow_ssh_config || cfg.allow_ssh_config;
49
+ }
50
+ if (typeof cfg.default_timeout_sec === 'number') {
51
+ merged.default_timeout_sec = cfg.default_timeout_sec;
52
+ }
53
+ } catch {
54
+ // Missing or malformed — skip
55
+ }
56
+ }
57
+
58
+ // Optionally import ~/.ssh/config hosts
59
+ if (merged.allow_ssh_config) {
60
+ const sshConfigHosts = parseSSHConfig();
61
+ for (const [name, entry] of Object.entries(sshConfigHosts)) {
62
+ if (!merged.hosts[name]) {
63
+ merged.hosts[name] = entry;
64
+ }
65
+ }
66
+ }
67
+
68
+ return merged;
69
+ }
70
+
71
+ function parseSSHConfig() {
72
+ const configPath = path.join(os.homedir(), '.ssh', 'config');
73
+ const hosts = {};
74
+ try {
75
+ const content = fs.readFileSync(configPath, 'utf8');
76
+ const lines = content.split(/\r?\n/);
77
+ let currentHost = null;
78
+ let currentEntry = null;
79
+
80
+ for (const raw of lines) {
81
+ const line = raw.trim();
82
+ if (!line || line.startsWith('#')) continue;
83
+
84
+ const m = line.match(/^(\S+)\s+(.+)$/);
85
+ if (!m) continue;
86
+
87
+ const key = m[1].toLowerCase();
88
+ const value = m[2].trim();
89
+
90
+ if (key === 'host') {
91
+ // Save previous entry
92
+ if (currentHost && currentEntry) {
93
+ currentEntry._aliases = currentHost.split(/\s+/);
94
+ for (const alias of currentEntry._aliases) {
95
+ if (alias !== '*') hosts[alias] = currentEntry;
96
+ }
97
+ }
98
+ currentHost = value;
99
+ currentEntry = { host: null, port: 22, auto_approve: false, _from_ssh_config: true };
100
+ } else if (currentEntry) {
101
+ if (key === 'hostname') currentEntry.host = value;
102
+ else if (key === 'port') currentEntry.port = parseInt(value, 10) || 22;
103
+ else if (key === 'user') {
104
+ // Merge user into the host string
105
+ const h = currentEntry.host || value;
106
+ currentEntry.host = `${value}@${h.replace(/^[^@]+@/, '')}`;
107
+ }
108
+ }
109
+ }
110
+ // Save last entry
111
+ if (currentHost && currentEntry) {
112
+ currentEntry._aliases = currentHost.split(/\s+/);
113
+ for (const alias of currentEntry._aliases) {
114
+ if (alias !== '*') hosts[alias] = currentEntry;
115
+ }
116
+ }
117
+ } catch {
118
+ // No config or unreadable
119
+ }
120
+ return hosts;
121
+ }
122
+
123
+ function resolveHost(cfg, name) {
124
+ const entry = cfg.hosts[name];
125
+ if (!entry) return null;
126
+ if (!entry.host) return null;
127
+ return entry;
128
+ }
129
+
130
+ // ── SSH command builder ─────────────────────────────────────────────────
131
+
132
+ function buildSSHArgs(entry, cmd) {
133
+ const args = ['ssh'];
134
+ if (entry.port && entry.port !== 22) {
135
+ args.push('-p', String(entry.port));
136
+ }
137
+ // Common options for non-interactive remote execution
138
+ args.push('-o', 'BatchMode=yes');
139
+ args.push('-o', 'StrictHostKeyChecking=accept-new');
140
+ args.push('-o', 'ConnectTimeout=10');
141
+ args.push(entry.host);
142
+ args.push(cmd);
143
+ return args;
144
+ }
145
+
146
+ function buildSCPArgs(entry, src, dest, direction) {
147
+ // direction: 'push' → local src → remote dest
148
+ // 'pull' → remote src → local dest
149
+ const args = ['scp'];
150
+ if (entry.port && entry.port !== 22) {
151
+ args.push('-P', String(entry.port));
152
+ }
153
+ args.push('-o', 'BatchMode=yes');
154
+ args.push('-o', 'StrictHostKeyChecking=accept-new');
155
+ args.push('-o', 'ConnectTimeout=10');
156
+
157
+ if (direction === 'push') {
158
+ args.push(src, `${entry.host}:${dest}`);
159
+ } else {
160
+ args.push(`${entry.host}:${src}`, dest);
161
+ }
162
+ return args;
163
+ }
164
+
165
+ // ── Execution ───────────────────────────────────────────────────────────
166
+
167
+ function sshRun(entry, cmd, signal) {
168
+ const timeout = (entry.timeout_sec || 30) * 1000;
169
+ const args = buildSSHArgs(entry, cmd);
170
+
171
+ return new Promise((resolve) => {
172
+ const child = execFile('ssh', args, {
173
+ timeout,
174
+ maxBuffer: 4 * 1024 * 1024,
175
+ }, (err, stdout, stderr) => {
176
+ if (err) {
177
+ const msg = (stderr || '').toString().trim() || err.message;
178
+ // Distinguish known SSH errors
179
+ if (err.killed) {
180
+ resolve({ error: `SSH timed out after ${timeout / 1000}s`, exitCode: null, stderr: msg });
181
+ } else {
182
+ resolve({
183
+ error: `SSH failed (exit ${err.code}): ${msg}`,
184
+ exitCode: err.code,
185
+ stderr: msg,
186
+ stdout: (stdout || '').toString().trim(),
187
+ });
188
+ }
189
+ return;
190
+ }
191
+ resolve({
192
+ ok: true,
193
+ stdout: (stdout || '').toString().trim(),
194
+ stderr: (stderr || '').toString().trim(),
195
+ });
196
+ });
197
+
198
+ if (signal) {
199
+ const onAbort = () => { try { child.kill('SIGINT'); } catch {} };
200
+ signal.addEventListener('abort', onAbort, { once: true });
201
+ }
202
+ });
203
+ }
204
+
205
+ function sshTransfer(entry, src, dest, direction, signal) {
206
+ const timeout = (entry.timeout_sec || 60) * 1000;
207
+ const args = buildSCPArgs(entry, src, dest, direction);
208
+
209
+ return new Promise((resolve) => {
210
+ const child = execFile('scp', args, {
211
+ timeout,
212
+ maxBuffer: 4 * 1024 * 1024,
213
+ }, (err, stdout, stderr) => {
214
+ if (err) {
215
+ const msg = (stderr || '').toString().trim() || err.message;
216
+ if (err.killed) {
217
+ resolve({ error: `SCP timed out after ${timeout / 1000}s`, stderr: msg });
218
+ } else {
219
+ resolve({
220
+ error: `SCP failed (exit ${err.code}): ${msg}`,
221
+ exitCode: err.code,
222
+ stderr: msg,
223
+ });
224
+ }
225
+ return;
226
+ }
227
+ resolve({ ok: true, stdout: (stdout || '').toString().trim() });
228
+ });
229
+
230
+ if (signal) {
231
+ const onAbort = () => { try { child.kill('SIGINT'); } catch {} };
232
+ signal.addEventListener('abort', onAbort, { once: true });
233
+ }
234
+ });
235
+ }
236
+
237
+ // ── Host listing ────────────────────────────────────────────────────────
238
+
239
+ function listHosts(cfg) {
240
+ return Object.entries(cfg.hosts).map(([name, entry]) => ({
241
+ name,
242
+ host: entry.host,
243
+ port: entry.port || 22,
244
+ auto_approve: !!entry.auto_approve,
245
+ from_ssh_config: !!entry._from_ssh_config,
246
+ }));
247
+ }
248
+
249
+ module.exports = {
250
+ loadHostConfig,
251
+ resolveHost,
252
+ sshRun,
253
+ sshTransfer,
254
+ listHosts,
255
+ };
package/src/subagent.js CHANGED
@@ -1,8 +1,19 @@
1
+ const { modelFor } = require('./llm');
2
+
1
3
  // Auto-subagent planning pass. Runs short read-only sub-calls before the
2
4
  // main agent loop to scope work, identify risks, and outline a plan.
3
5
  // Extracted from agent.js.
6
+ //
7
+ // Force triggers: phrases like "use agent team", "team mode", "pm mode",
8
+ // "agent mode", "multi-agent", or "subagent mode" always enable subagents
9
+ // regardless of heuristics or SHMAKK_AUTO_SUBAGENTS=0.
4
10
 
5
11
  function shouldUseAutoSubagents(input, roots) {
12
+ // Force team/PM mode when the user explicitly requests it, regardless of
13
+ // auto-detection heuristics or SHMAKK_AUTO_SUBAGENTS env override.
14
+ if (/(\buse\s+agent\s+team\b|\bteam\s+mode\b|\bpm\s+mode\b|\bagent\s+mode\b|\bmulti.?agent\b|\bsub.?agent\s+mode\b)/i.test(String(input || ''))) {
15
+ return true;
16
+ }
6
17
  if (String(process.env.SHMAKK_AUTO_SUBAGENTS || '1') === '0') return false;
7
18
  const minLen = Math.max(40, Number(process.env.SHMAKK_AUTO_SUBAGENTS_MIN_INPUT_LEN) || 140);
8
19
  const maxRoots = Math.max(1, Number(process.env.SHMAKK_AUTO_SUBAGENTS_MAX_ROOTS) || 2);
@@ -26,7 +37,7 @@ async function runAutoSubagents({ client, input, roots, signal }) {
26
37
  for (let i = 0; i < focuses.length; i++) {
27
38
  try {
28
39
  const r = await client.chat.completions.create({
29
- model: process.env.SHMAKK_MODEL || 'gpt-4o-mini',
40
+ model: modelFor('subagent-planning'),
30
41
  temperature: 0,
31
42
  stream: false,
32
43
  tool_choice: 'none',
@@ -1,4 +1,4 @@
1
- // Task classifier — matches makkorch's classification to ensure consistent routing.
1
+ // Task classifier — keeps routing consistent across model providers.
2
2
  // Analyzes message content to detect task type (architecture, implementation, debugging, etc).
3
3
 
4
4
  function normalizeContent(content) {
@@ -64,7 +64,7 @@ function makeProfile(taskType, scope, reasoning, urgency, risk) {
64
64
  };
65
65
  }
66
66
 
67
- // Classify task type from messages (matches makkorch's classifyTask.ts)
67
+ // Classify task type from messages.
68
68
  function classifyTask(messages) {
69
69
  const text = messagesToText(messages);
70
70
 
package/src/team.js CHANGED
@@ -21,6 +21,7 @@ const os = require('os');
21
21
  const { makeClient, modelFor, isConfigured } = require('./llm');
22
22
  const { runAgent } = require('./agent');
23
23
  const { matchWorkflow, expandWorkflow } = require('./workflows');
24
+ const agentOverview = require('./agent-overview');
24
25
 
25
26
  // Role → preferred skill name mapping. Most agents can be powered by a real
26
27
  // skill file from ~/.config/shmakk/skills/. When a skill file exists, its
@@ -422,6 +423,16 @@ async function runSubAgent({
422
423
  // Step 2: try to load the skill content from the catalog.
423
424
  const loaded = loadSkillContent(wantedSkill, roots);
424
425
 
426
+ // Register agent in the overview tracker.
427
+ const agentId = agentOverview.register(null, {
428
+ role,
429
+ skill: wantedSkill,
430
+ skillSource: loaded ? loaded.source : null,
431
+ task,
432
+ fileScope: fileScope || null,
433
+ topology,
434
+ });
435
+
425
436
  // Step 3: fall back to AGENT_ROSTER if no skill file found.
426
437
  const roster = AGENT_ROSTER[role];
427
438
  if (!loaded && !roster) {
@@ -512,6 +523,7 @@ async function runSubAgent({
512
523
  };
513
524
 
514
525
  try {
526
+ agentOverview.markRunning(agentId);
515
527
  await runOnce(subInput);
516
528
 
517
529
  // Retry once if the agent produced 0 tool calls — it likely got stuck in
@@ -540,6 +552,13 @@ async function runSubAgent({
540
552
  await runOnce(retryInput, { hintOverride: retryHint, requireTool: true });
541
553
  }
542
554
 
555
+ agentOverview.markDone(agentId, {
556
+ toolCount,
557
+ output: lines.join(''),
558
+ skill: loaded ? wantedSkill : null,
559
+ skillSource: loaded ? loaded.source : null,
560
+ });
561
+
543
562
  return {
544
563
  role, task,
545
564
  output: lines.join(''),
@@ -548,6 +567,7 @@ async function runSubAgent({
548
567
  skillUsed: loaded ? wantedSkill : null,
549
568
  };
550
569
  } catch (e) {
570
+ agentOverview.markError(agentId, e.message);
551
571
  return {
552
572
  role, task,
553
573
  output: lines.join(''),
@@ -735,6 +755,7 @@ async function runTeam({ input, roots, write, signal, mcpManager }) {
735
755
  write('\r\n');
736
756
 
737
757
  // Phase 2: Execute based on topology
758
+ agentOverview.startTeamRun(`team-${Date.now()}`);
738
759
  const agentResults = topology === 'pipeline'
739
760
  ? await runPipelineAgents({ agents, overallInput: input, roots, signal, mcpManager, write })
740
761
  : await runParallelAgents({ agents, overallInput: input, roots, signal, mcpManager, write });
@@ -746,6 +767,7 @@ async function runTeam({ input, roots, write, signal, mcpManager }) {
746
767
  const summary = await synthesizeResults({ overallInput: input, agentResults, client, signal });
747
768
  write(summary + '\r\n');
748
769
 
770
+ agentOverview.endTeamRun();
749
771
  return true;
750
772
  }
751
773