lazyclaw 4.2.2 → 4.3.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.
package/cli.mjs CHANGED
@@ -56,6 +56,17 @@ function _resolveAuthKey(cfg, provider) {
56
56
  return cfg['api-key'] || '';
57
57
  }
58
58
 
59
+ // Per-provider base-URL override (used by tests + private gateways).
60
+ // Single source of truth — the reflect / skill-synth / task-tick paths
61
+ // all resolve through here so a new provider's env var lands in one spot.
62
+ function _resolveBaseUrl(provider) {
63
+ return {
64
+ anthropic: process.env.LAZYCLAW_ANTHROPIC_BASE_URL,
65
+ openai: process.env.LAZYCLAW_OPENAI_BASE_URL,
66
+ gemini: process.env.LAZYCLAW_GEMINI_BASE_URL,
67
+ }[provider] || undefined;
68
+ }
69
+
59
70
  async function importWorkflow(file) {
60
71
  const abs = path.resolve(file);
61
72
  const url = pathToFileURL(abs).href;
@@ -924,6 +935,10 @@ const SUBCOMMANDS = [
924
935
  'loop', 'loops', 'goal', 'memory',
925
936
  // v4.0.0 — Slack Socket Mode listener (inbound DM / @-mention)
926
937
  'slack',
938
+ // v4.3.0 — Telegram long-poll listener (zero-install mobile control)
939
+ 'telegram',
940
+ // v4.3.0 — Matrix /sync long-poll listener
941
+ 'matrix',
927
942
  // v4.1.0 — multi-agent slack system (Phase 9+)
928
943
  'agent', 'team', 'task',
929
944
  ];
@@ -931,13 +946,13 @@ const SUBCOMMANDS = [
931
946
  const SUBCOMMAND_SUBS = {
932
947
  config: ['get', 'set', 'list', 'delete', 'unset', 'path', 'edit', 'validate'],
933
948
  sessions: ['list', 'show', 'clear', 'export', 'search'],
934
- skills: ['list', 'show', 'install', 'remove', 'search'],
949
+ skills: ['list', 'show', 'install', 'remove', 'search', 'curate', 'classify'],
935
950
  providers: ['list', 'info', 'test', 'add', 'remove', 'models'],
936
951
  rates: ['list', 'set', 'delete', 'shape', 'validate', 'copy'],
937
952
  completion: ['bash', 'zsh'],
938
953
  auth: ['list', 'add', 'remove', 'use', 'rotate'],
939
954
  pairing: ['list', 'add', 'remove'],
940
- nodes: ['list', 'register', 'remove'],
955
+ nodes: ['list', 'register', 'remove', 'pending', 'approve', 'revoke', 'devices'],
941
956
  message: ['list', 'add', 'remove', 'send'],
942
957
  workspace: ['list', 'init', 'show', 'remove', 'path'],
943
958
  cron: ['list', 'add', 'remove', 'show', 'sync', 'run'],
@@ -946,6 +961,8 @@ const SUBCOMMAND_SUBS = {
946
961
  goal: ['add', 'list', 'show', 'close', 'switch', 'tick', 'channel'],
947
962
  memory: ['show', 'dream', 'edit'],
948
963
  slack: ['listen'],
964
+ telegram: ['listen'],
965
+ matrix: ['listen'],
949
966
  agent: ['add', 'list', 'show', 'edit', 'remove'],
950
967
  team: ['add', 'list', 'show', 'edit', 'remove'],
951
968
  task: ['start', 'list', 'show', 'abandon', 'done', 'remove'],
@@ -3577,8 +3594,51 @@ async function cmdNodes(sub, positional, flags = {}) {
3577
3594
  console.log(JSON.stringify({ ok: true, removed: id }));
3578
3595
  return;
3579
3596
  }
3597
+ // Device-gateway pairing (Phase 27) — distinct from the config-based
3598
+ // `nodes register` table above. These drive the Ed25519 PairingStore
3599
+ // a companion node authenticates against via `lazyclaw daemon`.
3600
+ case 'pending': {
3601
+ const { PairingStore } = await import('./gateway/device_auth.mjs');
3602
+ const store = new PairingStore(path.dirname(configPath()));
3603
+ console.log(JSON.stringify(store.pending(), null, 2));
3604
+ return;
3605
+ }
3606
+ case 'devices': {
3607
+ const { PairingStore } = await import('./gateway/device_auth.mjs');
3608
+ const store = new PairingStore(path.dirname(configPath()));
3609
+ console.log(JSON.stringify(store.devicesList(), null, 2));
3610
+ return;
3611
+ }
3612
+ case 'approve': {
3613
+ const requestId = positional[0];
3614
+ if (!requestId) {
3615
+ console.error('Usage: lazyclaw nodes approve <requestId> (see `lazyclaw nodes pending`)');
3616
+ process.exit(2);
3617
+ }
3618
+ const { PairingStore } = await import('./gateway/device_auth.mjs');
3619
+ const store = new PairingStore(path.dirname(configPath()));
3620
+ try {
3621
+ const { deviceId } = store.approve(requestId);
3622
+ // The token is intentionally NOT printed — the device receives its
3623
+ // rotated token on its next /gateway/connect, so it never has to
3624
+ // pass through a terminal / shell history.
3625
+ console.log(JSON.stringify({ ok: true, approved: requestId, deviceId, note: 'device receives its token on next /gateway/connect' }));
3626
+ } catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
3627
+ return;
3628
+ }
3629
+ case 'revoke': {
3630
+ const deviceId = positional[0];
3631
+ if (!deviceId) {
3632
+ console.error('Usage: lazyclaw nodes revoke <deviceId>');
3633
+ process.exit(2);
3634
+ }
3635
+ const { PairingStore } = await import('./gateway/device_auth.mjs');
3636
+ const store = new PairingStore(path.dirname(configPath()));
3637
+ console.log(JSON.stringify(store.revoke(deviceId)));
3638
+ return;
3639
+ }
3580
3640
  default:
3581
- console.error('Usage: lazyclaw nodes <list|register|remove> ...');
3641
+ console.error('Usage: lazyclaw nodes <list|register|remove|pending|approve <requestId>|revoke <deviceId>|devices> ...');
3582
3642
  process.exit(2);
3583
3643
  }
3584
3644
  }
@@ -4382,7 +4442,7 @@ async function cmdMemory(sub, positional, flags = {}) {
4382
4442
  }
4383
4443
  }
4384
4444
 
4385
- const AGENT_REG_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete', 'memory', 'reflect']);
4445
+ const AGENT_REG_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete', 'memory', 'reflect', 'skill-synth']);
4386
4446
  const TEAM_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete']);
4387
4447
  const TASK_SUBS = new Set(['start', 'tick', 'list', 'show', 'abandon', 'done', 'transcript', 'remove', 'rm', 'delete']);
4388
4448
 
@@ -4510,14 +4570,36 @@ async function cmdTask(sub, positional, flags = {}) {
4510
4570
  const cfg = readConfig();
4511
4571
  const leadAgent = agentsById[team.lead];
4512
4572
  const apiKey = _resolveAuthKey(cfg, leadAgent.provider);
4513
- // Per-provider base-url override. Mostly useful for tests that
4514
- // point the adapter at a local mock; production users get the
4515
- // built-in default by leaving these unset.
4516
- const baseUrl = {
4517
- anthropic: process.env.LAZYCLAW_ANTHROPIC_BASE_URL,
4518
- openai: process.env.LAZYCLAW_OPENAI_BASE_URL,
4519
- gemini: process.env.LAZYCLAW_GEMINI_BASE_URL,
4520
- }[leadAgent.provider] || undefined;
4573
+ // Per-provider base-url override (tests point this at a local mock;
4574
+ // production leaves it unset for the built-in default).
4575
+ const baseUrl = _resolveBaseUrl(leadAgent.provider);
4576
+ // --approve-url turns on remote human-in-the-loop approval for the
4577
+ // sensitive tools (bash/write): each such call long-polls a running
4578
+ // daemon's `POST /exec/request`, which broadcasts to paired devices
4579
+ // over the gateway SSE and resolves when one approves. Fail-closed —
4580
+ // any endpoint error denies the call. Omit the flag → ungated (the
4581
+ // historical behavior).
4582
+ let approve;
4583
+ if (flags['approve-url']) {
4584
+ const approveUrl = String(flags['approve-url']).replace(/\/$/, '');
4585
+ const approveToken = flags['approve-token'] ? String(flags['approve-token']) : '';
4586
+ const approveTimeoutMs = flags['approve-timeout'] ? parseInt(flags['approve-timeout'], 10) : 120000;
4587
+ approve = async ({ tool, args, agent }) => {
4588
+ const summary = `${tool}: ${typeof args === 'object' ? JSON.stringify(args) : String(args)}`.slice(0, 400);
4589
+ try {
4590
+ const r = await fetch(`${approveUrl}/exec/request`, {
4591
+ method: 'POST',
4592
+ headers: { 'content-type': 'application/json', ...(approveToken ? { authorization: `Bearer ${approveToken}` } : {}) },
4593
+ body: JSON.stringify({ tool, agentId: agent, summary, timeoutMs: approveTimeoutMs }),
4594
+ });
4595
+ if (!r.ok) return { approved: false, reason: `approval endpoint HTTP ${r.status}` };
4596
+ const j = await r.json();
4597
+ return { approved: !!j.approved, reason: j.reason || (j.approved ? 'approved' : 'denied') };
4598
+ } catch (err) {
4599
+ return { approved: false, reason: `approval request failed: ${err?.message || err}` };
4600
+ }
4601
+ };
4602
+ }
4521
4603
  try {
4522
4604
  const result = await router.runTaskTurn({
4523
4605
  task, team, agentsById,
@@ -4527,6 +4609,7 @@ async function cmdTask(sub, positional, flags = {}) {
4527
4609
  baseUrl,
4528
4610
  logger: (line) => process.stderr.write(line),
4529
4611
  maxAgentTurns: flags['max-turns'] ? parseInt(flags['max-turns'], 10) : undefined,
4612
+ approve,
4530
4613
  });
4531
4614
  emitJson({ id: result.task.id, status: result.task.status, iterations: result.iterations, stoppedBy: result.stoppedBy });
4532
4615
  } catch (err) {
@@ -4685,7 +4768,7 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
4685
4768
  return;
4686
4769
  }
4687
4770
  case 'add': {
4688
- if (!name) { console.error('Usage: lazyclaw agent add <name> [--role "..."] [--provider X] [--model Y] [--display "..."] [--tools bash,read,write,grep] [--tags a,b]'); process.exit(2); }
4771
+ if (!name) { console.error('Usage: lazyclaw agent add <name> [--role "..."] [--provider X] [--model Y] [--display "..."] [--tools bash,read,write,grep,skill_view] [--tags a,b] [--skill-write auto|manual|off]'); process.exit(2); }
4689
4772
  const tools = agentsMod.parseToolsFlag(flags.tools);
4690
4773
  try {
4691
4774
  const a = agentsMod.registerAgent({
@@ -4696,6 +4779,7 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
4696
4779
  model: flags.model || '',
4697
4780
  tools: tools === null ? undefined : tools,
4698
4781
  tags: agentsMod.parseToolsFlag(flags.tags) || [],
4782
+ skillWrite: flags['skill-write'],
4699
4783
  }, cfgDir);
4700
4784
  emitJson(a);
4701
4785
  } catch (err) {
@@ -4712,7 +4796,7 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
4712
4796
  return;
4713
4797
  }
4714
4798
  case 'edit': {
4715
- if (!name) { console.error('Usage: lazyclaw agent edit <name> [--role "..."] [--provider X] [--model Y] [--display "..."] [--tools ...]'); process.exit(2); }
4799
+ if (!name) { console.error('Usage: lazyclaw agent edit <name> [--role "..."] [--provider X] [--model Y] [--display "..."] [--tools ...] [--skill-write auto|manual|off] [--memory-write auto|manual|off]'); process.exit(2); }
4716
4800
  const patch = {};
4717
4801
  if (flags.role !== undefined) patch.role = String(flags.role);
4718
4802
  if (flags.provider !== undefined) patch.provider = String(flags.provider);
@@ -4721,6 +4805,8 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
4721
4805
  if (flags['display-name'] !== undefined) patch.displayName = String(flags['display-name']);
4722
4806
  if (flags.tools !== undefined) patch.tools = agentsMod.parseToolsFlag(flags.tools);
4723
4807
  if (flags.tags !== undefined) patch.tags = agentsMod.parseToolsFlag(flags.tags);
4808
+ if (flags['skill-write'] !== undefined) patch.skillWrite = String(flags['skill-write']);
4809
+ if (flags['memory-write'] !== undefined) patch.memoryWrite = String(flags['memory-write']);
4724
4810
  if (Object.keys(patch).length === 0) {
4725
4811
  console.error('agent edit: no fields to update');
4726
4812
  process.exit(2);
@@ -4796,11 +4882,7 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
4796
4882
  try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
4797
4883
  const cfg = readConfig();
4798
4884
  const apiKey = _resolveAuthKey(cfg, a.provider);
4799
- const baseUrl = {
4800
- anthropic: process.env.LAZYCLAW_ANTHROPIC_BASE_URL,
4801
- openai: process.env.LAZYCLAW_OPENAI_BASE_URL,
4802
- gemini: process.env.LAZYCLAW_GEMINI_BASE_URL,
4803
- }[a.provider] || undefined;
4885
+ const baseUrl = _resolveBaseUrl(a.provider);
4804
4886
  try {
4805
4887
  const body = await memMod.reflectOnce({ agent: a, task, apiKey, baseUrl });
4806
4888
  if (!body || !body.trim()) {
@@ -4817,8 +4899,50 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
4817
4899
  }
4818
4900
  return;
4819
4901
  }
4902
+ case 'skill-synth': {
4903
+ const aname = positional[0];
4904
+ const taskId = flags.task || positional[1];
4905
+ if (!aname || !taskId) {
4906
+ console.error('Usage: lazyclaw agent skill-synth <name> --task <id> [--dry-run]');
4907
+ process.exit(2);
4908
+ }
4909
+ const tasksMod = await import('./tasks.mjs');
4910
+ const synthMod = await import('./mas/skill_synth.mjs');
4911
+ const skillsMod = await import('./skills.mjs');
4912
+ const a = agentsMod.getAgent(aname, cfgDir);
4913
+ if (!a) { console.error(`agent skill-synth: no agent "${aname}"`); process.exit(2); }
4914
+ const task = tasksMod.getTask(taskId, cfgDir);
4915
+ if (!task) { console.error(`agent skill-synth: no task "${taskId}"`); process.exit(2); }
4916
+ try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
4917
+ const cfg = readConfig();
4918
+ const apiKey = _resolveAuthKey(cfg, a.provider);
4919
+ const baseUrl = _resolveBaseUrl(a.provider);
4920
+ try {
4921
+ const result = await synthMod.synthesizeSkill({ agent: a, task, apiKey, baseUrl });
4922
+ if (!result) {
4923
+ process.stderr.write('skill synthesis produced nothing worth saving\n');
4924
+ return;
4925
+ }
4926
+ if (!flags['dry-run']) {
4927
+ // installSynthesized reserves a collision-free name (never
4928
+ // clobbers a human-authored skill) and version-bumps when it
4929
+ // improves its own prior skill.
4930
+ const installed = synthMod.installSynthesized(
4931
+ { name: result.name, description: result.description, body: result.body, sourceTask: task.id },
4932
+ cfgDir,
4933
+ );
4934
+ emitJson({ skill: installed.skill, description: result.description, version: installed.version, path: installed.path });
4935
+ } else {
4936
+ process.stdout.write(result.doc + (result.doc.endsWith('\n') ? '' : '\n'));
4937
+ }
4938
+ } catch (err) {
4939
+ console.error(`agent skill-synth: ${err?.message || err}`);
4940
+ process.exit(2);
4941
+ }
4942
+ return;
4943
+ }
4820
4944
  default:
4821
- console.error('Usage: lazyclaw agent <add|list|show|edit|remove|memory|reflect> ...');
4945
+ console.error('Usage: lazyclaw agent <add|list|show|edit|remove|memory|reflect|skill-synth> ...');
4822
4946
  process.exit(2);
4823
4947
  }
4824
4948
  }
@@ -4929,6 +5053,165 @@ async function cmdSlack(sub, positional, flags = {}) {
4929
5053
  });
4930
5054
  }
4931
5055
 
5056
+ // `lazyclaw telegram listen` — zero-install mobile control surface.
5057
+ // Long-polls the Telegram Bot API (no public URL / webhook needed) and
5058
+ // pipes each inbound message through the active provider, replying in
5059
+ // the same chat. Mirrors `slack listen`. Access is gated by the existing
5060
+ // `pairing` allowlist (Telegram numeric user ids); an empty allowlist
5061
+ // means "reply to anyone who can reach the bot".
5062
+ async function cmdTelegram(sub, positional, flags = {}) {
5063
+ if (sub !== 'listen') {
5064
+ console.error('Usage: lazyclaw telegram listen [--provider X] [--model Y]\n Long-polls the Telegram Bot API. Set TELEGRAM_BOT_TOKEN in ~/.lazyclaw/.env.\n Restrict who can talk to it with `lazyclaw pairing add <telegram-user-id>`.');
5065
+ process.exit(2);
5066
+ }
5067
+ await ensureRegistry();
5068
+ const cfg = readConfig();
5069
+ const cfgDir = path.dirname(configPath());
5070
+
5071
+ const envInfo = _loadDotenvIfAny(cfgDir);
5072
+ process.stderr.write(`[telegram] .env: ${envInfo.loaded} keys loaded from ${envInfo.path}\n`);
5073
+
5074
+ const provName = flags.provider || cfg.provider || 'mock';
5075
+ const prov = _registryMod.PROVIDERS[provName];
5076
+ if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
5077
+ const model = flags.model || cfg.model;
5078
+
5079
+ const threadMsgs = new Map();
5080
+ const MAX_TURNS = 20;
5081
+
5082
+ const handler = async ({ threadId, text }) => {
5083
+ const cleaned = String(text || '').trim();
5084
+ if (!cleaned) { process.stderr.write('[telegram] dropping empty inbound\n'); return null; }
5085
+ const msgs = threadMsgs.get(threadId) || [];
5086
+ msgs.push({ role: 'user', content: cleaned });
5087
+ let acc = '';
5088
+ try {
5089
+ for await (const chunk of prov.sendMessage(msgs, { apiKey: _resolveAuthKey(cfg, provName), model })) acc += chunk;
5090
+ } catch (err) {
5091
+ msgs.pop();
5092
+ const why = err?.message || String(err);
5093
+ process.stderr.write(`[telegram] provider error: ${why}\n`);
5094
+ return `(provider error: ${why})`;
5095
+ }
5096
+ msgs.push({ role: 'assistant', content: acc });
5097
+ if (msgs.length > MAX_TURNS) msgs.splice(0, msgs.length - MAX_TURNS);
5098
+ threadMsgs.set(threadId, msgs);
5099
+ if (!acc.trim()) { process.stderr.write('[telegram] provider returned empty text — not posting\n'); return null; }
5100
+ return acc;
5101
+ };
5102
+
5103
+ // The pairing allowlist doubles as the Telegram sender allowlist.
5104
+ const allowlist = (cfg.pairing || []).map((p) => String(p.id));
5105
+ const { TelegramChannel } = await import('./channels/telegram.mjs');
5106
+ let ch;
5107
+ try {
5108
+ ch = new TelegramChannel({ allowlist: allowlist.length ? allowlist : null });
5109
+ } catch (err) {
5110
+ console.error(`telegram: ${err?.message || err}`);
5111
+ process.exit(2);
5112
+ }
5113
+ process.stderr.write(`[telegram] provider=${provName} model=${model || '(default)'} allowlist=${allowlist.length || 'open'}\n`);
5114
+ try {
5115
+ await ch.start(handler, { poll: true, logger: (line) => process.stderr.write(line) });
5116
+ } catch (err) {
5117
+ if (err?.code === 'TELEGRAM_MISSING_TOKEN') {
5118
+ console.error('telegram: TELEGRAM_BOT_TOKEN not set');
5119
+ console.error(`hint: add TELEGRAM_BOT_TOKEN=... to ${path.join(cfgDir, '.env')}`);
5120
+ } else {
5121
+ console.error(`telegram: ${err?.message || err}`);
5122
+ }
5123
+ process.exit(2);
5124
+ }
5125
+ process.stderr.write(`[telegram] listening. Ctrl-C to stop.\n`);
5126
+
5127
+ await new Promise((resolve) => {
5128
+ const onSig = async () => {
5129
+ process.stderr.write(`\n[telegram] shutting down…\n`);
5130
+ try { await ch.stop(); } catch { /* best-effort */ }
5131
+ resolve();
5132
+ };
5133
+ process.once('SIGINT', onSig);
5134
+ process.once('SIGTERM', onSig);
5135
+ });
5136
+ }
5137
+
5138
+ // `lazyclaw matrix listen` — Matrix inbound over the client-server API's
5139
+ // long-poll /sync (no SDK). Mirrors `telegram listen`. Set MATRIX_HOMESERVER
5140
+ // + MATRIX_ACCESS_TOKEN (+ MATRIX_USER_ID for self-filtering) in ~/.lazyclaw/.env.
5141
+ async function cmdMatrix(sub, positional, flags = {}) {
5142
+ if (sub !== 'listen') {
5143
+ console.error('Usage: lazyclaw matrix listen [--provider X] [--model Y]\n Long-polls the Matrix /sync API. Set MATRIX_HOMESERVER + MATRIX_ACCESS_TOKEN (+ MATRIX_USER_ID) in ~/.lazyclaw/.env.\n Restrict who can talk to it with `lazyclaw pairing add <@user:server>`.');
5144
+ process.exit(2);
5145
+ }
5146
+ await ensureRegistry();
5147
+ const cfg = readConfig();
5148
+ const cfgDir = path.dirname(configPath());
5149
+
5150
+ const envInfo = _loadDotenvIfAny(cfgDir);
5151
+ process.stderr.write(`[matrix] .env: ${envInfo.loaded} keys loaded from ${envInfo.path}\n`);
5152
+
5153
+ const provName = flags.provider || cfg.provider || 'mock';
5154
+ const prov = _registryMod.PROVIDERS[provName];
5155
+ if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
5156
+ const model = flags.model || cfg.model;
5157
+
5158
+ const threadMsgs = new Map();
5159
+ const MAX_TURNS = 20;
5160
+ const handler = async ({ threadId, text }) => {
5161
+ const cleaned = String(text || '').trim();
5162
+ if (!cleaned) { process.stderr.write('[matrix] dropping empty inbound\n'); return null; }
5163
+ const msgs = threadMsgs.get(threadId) || [];
5164
+ msgs.push({ role: 'user', content: cleaned });
5165
+ let acc = '';
5166
+ try {
5167
+ for await (const chunk of prov.sendMessage(msgs, { apiKey: _resolveAuthKey(cfg, provName), model })) acc += chunk;
5168
+ } catch (err) {
5169
+ msgs.pop();
5170
+ const why = err?.message || String(err);
5171
+ process.stderr.write(`[matrix] provider error: ${why}\n`);
5172
+ return `(provider error: ${why})`;
5173
+ }
5174
+ msgs.push({ role: 'assistant', content: acc });
5175
+ if (msgs.length > MAX_TURNS) msgs.splice(0, msgs.length - MAX_TURNS);
5176
+ threadMsgs.set(threadId, msgs);
5177
+ if (!acc.trim()) { process.stderr.write('[matrix] provider returned empty text — not posting\n'); return null; }
5178
+ return acc;
5179
+ };
5180
+
5181
+ const allowlist = (cfg.pairing || []).map((p) => String(p.id));
5182
+ const { MatrixChannel } = await import('./channels/matrix.mjs');
5183
+ let ch;
5184
+ try {
5185
+ ch = new MatrixChannel({ allowlist: allowlist.length ? allowlist : null });
5186
+ } catch (err) {
5187
+ console.error(`matrix: ${err?.message || err}`);
5188
+ process.exit(2);
5189
+ }
5190
+ process.stderr.write(`[matrix] provider=${provName} model=${model || '(default)'} allowlist=${allowlist.length || 'open'}\n`);
5191
+ try {
5192
+ await ch.start(handler, { poll: true, logger: (line) => process.stderr.write(line) });
5193
+ } catch (err) {
5194
+ if (err?.code === 'MATRIX_MISSING_TOKEN' || err?.code === 'MATRIX_MISSING_HOMESERVER') {
5195
+ console.error(`matrix: ${err.message}`);
5196
+ console.error(`hint: set MATRIX_HOMESERVER and MATRIX_ACCESS_TOKEN in ${path.join(cfgDir, '.env')}`);
5197
+ } else {
5198
+ console.error(`matrix: ${err?.message || err}`);
5199
+ }
5200
+ process.exit(2);
5201
+ }
5202
+ process.stderr.write(`[matrix] listening. Ctrl-C to stop.\n`);
5203
+
5204
+ await new Promise((resolve) => {
5205
+ const onSig = async () => {
5206
+ process.stderr.write(`\n[matrix] shutting down…\n`);
5207
+ try { await ch.stop(); } catch { /* best-effort */ }
5208
+ resolve();
5209
+ };
5210
+ process.once('SIGINT', onSig);
5211
+ process.once('SIGTERM', onSig);
5212
+ });
5213
+ }
5214
+
4932
5215
  async function cmdSkills(sub, positional, flags = {}) {
4933
5216
  const skillsMod = await import('./skills.mjs');
4934
5217
  const cfgDir = path.dirname(configPath());
@@ -5120,8 +5403,24 @@ async function cmdSkills(sub, positional, flags = {}) {
5120
5403
  console.log(JSON.stringify({ query, regex: useRegex, matches }, null, 2));
5121
5404
  return;
5122
5405
  }
5406
+ case 'curate': {
5407
+ // Lifecycle sweep: agent-authored skills unused >90d move into
5408
+ // skills/.archive/ (recoverable). Human-authored skills are never
5409
+ // moved. The real clock is injected here; the module stays pure.
5410
+ const curator = await import('./skills_curator.mjs');
5411
+ const r = curator.curate(cfgDir, Date.now());
5412
+ console.log(JSON.stringify(r, null, 2));
5413
+ return;
5414
+ }
5415
+ case 'classify': {
5416
+ const name = positional[0];
5417
+ if (!name) { console.error('Usage: lazyclaw skills classify <name>'); process.exit(2); }
5418
+ const curator = await import('./skills_curator.mjs');
5419
+ console.log(JSON.stringify({ name, state: curator.classify(name, cfgDir, Date.now()), usage: curator.usageOf(name, cfgDir) }, null, 2));
5420
+ return;
5421
+ }
5123
5422
  default:
5124
- console.error('Usage: lazyclaw skills <list|show <name>|install <name> [--from path]|remove <name>|search <query> [--regex]>');
5423
+ console.error('Usage: lazyclaw skills <list|show <name>|install <name> [--from path]|remove <name>|search <query> [--regex]|curate|classify <name>>');
5125
5424
  process.exit(2);
5126
5425
  }
5127
5426
  }
@@ -5997,6 +6296,8 @@ async function _dispatchMenuChoice(argv) {
5997
6296
  case 'goal': return await cmdGoal(rest[0], rest.slice(1), {});
5998
6297
  case 'memory': return await cmdMemory(rest[0], rest.slice(1), {});
5999
6298
  case 'slack': return await cmdSlack(rest[0], rest.slice(1), {});
6299
+ case 'telegram': return await cmdTelegram(rest[0], rest.slice(1), {});
6300
+ case 'matrix': return await cmdMatrix(rest[0], rest.slice(1), {});
6000
6301
  case 'team': return await cmdTeam(rest[0], rest.slice(1), {});
6001
6302
  case 'task': return await cmdTask(rest[0], rest.slice(1), {});
6002
6303
  case 'auth': return await cmdAuth(rest[0], rest.slice(1), {});
@@ -6548,6 +6849,16 @@ async function main() {
6548
6849
  await cmdSlack(sub, rest.positional.slice(1), rest.flags);
6549
6850
  break;
6550
6851
  }
6852
+ case 'telegram': {
6853
+ const sub = rest.positional[0];
6854
+ await cmdTelegram(sub, rest.positional.slice(1), rest.flags);
6855
+ break;
6856
+ }
6857
+ case 'matrix': {
6858
+ const sub = rest.positional[0];
6859
+ await cmdMatrix(sub, rest.positional.slice(1), rest.flags);
6860
+ break;
6861
+ }
6551
6862
  case 'team': {
6552
6863
  const sub = rest.positional[0];
6553
6864
  await cmdTeam(sub, rest.positional.slice(1), rest.flags);
package/daemon.mjs CHANGED
@@ -21,6 +21,8 @@ import { withFallback } from './providers/fallback.mjs';
21
21
  import { withResponseCache } from './providers/cache.mjs';
22
22
  import { costFromUsage, RATE_CARD_SHAPE } from './providers/rates.mjs';
23
23
  import { composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill } from './skills.mjs';
24
+ import { ChallengeRegistry } from './gateway/device_auth.mjs';
25
+ import { createGateway } from './gateway/http_gateway.mjs';
24
26
  import { TokenBucketLimiter } from './ratelimit.mjs';
25
27
  import { createLogger } from './logger.mjs';
26
28
  import { summarizeState, listSessions as listWorkflowSessions, loadStateFile as loadWorkflowState, aggregateNodeStats } from './workflow/summary.mjs';
@@ -321,6 +323,11 @@ export function makeHandler(ctx) {
321
323
  costsByCurrency: /** @type {Record<string, number>} */({}),
322
324
  tokensTotal: { inputTokens: 0, outputTokens: 0 },
323
325
  };
326
+ // Device gateway (Phase 27). The ChallengeRegistry is a per-process
327
+ // singleton: a challenge minted by one request is consumed by a later
328
+ // one, so it must outlive a single call.
329
+ const gwConfigDir = typeof ctx.sessionsDirGetter === 'function' ? ctx.sessionsDirGetter() : undefined;
330
+ const gateway = createGateway({ configDir: gwConfigDir, challengeRegistry: new ChallengeRegistry(), heartbeatMs: 25000 });
324
331
  return async function handler(req, res) {
325
332
  // Capture method+path before any handler logic runs; req.url survives
326
333
  // the response but capturing now keeps the log line stable even if a
@@ -361,6 +368,31 @@ export function makeHandler(ctx) {
361
368
  if (!isOriginAllowed(req, allowedOrigins, allowLoopback)) {
362
369
  return writeJson(res, 403, { error: 'forbidden origin' });
363
370
  }
371
+ // Device gateway (Phase 27) — routed BEFORE the shared auth-token
372
+ // gate. Companion-node auth is the gateway's own Ed25519 device-auth
373
+ // (challenge/sign/approve + bearer token); the only unauthenticated
374
+ // route, /gateway/connect/challenge, returns nothing but a random
375
+ // nonce. The bypass decision uses the NORMALIZED pathname (the same
376
+ // one the gateway routes on) so a dot-segment path like
377
+ // `/gateway/../sessions` can't skip the auth-token gate — it
378
+ // normalizes to `/sessions`, fails this prefix test, and falls
379
+ // through to the protected handler. When the limiter is enabled,
380
+ // gateway traffic uses its own key namespace so an unauthenticated
381
+ // flood can't drain the authenticated user's per-IP budget.
382
+ let gwPath = '';
383
+ try { gwPath = new URL(req.url || '/', 'http://localhost').pathname; } catch { gwPath = ''; }
384
+ if (gwPath.startsWith('/gateway/')) {
385
+ if (limiter) {
386
+ const key = 'gw:' + (req.socket?.remoteAddress || 'no-socket');
387
+ const verdict = limiter.consume(key);
388
+ if (!verdict.allowed) {
389
+ metrics.rateLimitDenied += 1;
390
+ const retrySeconds = Math.max(1, Math.ceil(verdict.retryAfterMs / 1000));
391
+ return writeJson(res, 429, { error: 'rate limit exceeded', retryAfterMs: verdict.retryAfterMs }, { 'retry-after': String(retrySeconds) });
392
+ }
393
+ }
394
+ return await gateway.handle(req, res, { readBody: readTextBody });
395
+ }
364
396
  // Authentication gate — when authToken is set, every request must
365
397
  // present `Authorization: Bearer <token>`. This is opt-in because
366
398
  // the default deployment is loopback-only single-user; the token
@@ -430,6 +462,26 @@ export function makeHandler(ctx) {
430
462
  }
431
463
  case route === 'GET /version':
432
464
  return writeJson(res, 200, { version: ctx.version(), nodeVersion: process.version, platform: `${process.platform}-${process.arch}` });
465
+ case route === 'POST /exec/request': {
466
+ // Remote exec-approval bridge. This route is AUTH-TOKEN-GATED
467
+ // (above), so only the trusted local operator/CLI can REQUEST an
468
+ // approval; a paired mobile device RESOLVES it over the gateway
469
+ // (POST /gateway/exec/resolve). The route long-polls: it awaits
470
+ // the device's decision (or the approval's timeout) and returns
471
+ // { approved, by, reason }.
472
+ let body;
473
+ try { body = await readJson(req); }
474
+ catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
475
+ if (!body || typeof body.tool !== 'string' || !body.tool) {
476
+ return writeJson(res, 400, { error: 'tool is required' });
477
+ }
478
+ const { promise } = gateway.requestApproval(
479
+ { tool: body.tool, args: body.args, agentId: body.agentId, summary: body.summary },
480
+ { timeoutMs: Number.isFinite(+body.timeoutMs) ? +body.timeoutMs : undefined },
481
+ );
482
+ const result = await promise;
483
+ return writeJson(res, 200, result);
484
+ }
433
485
  case route === 'GET /health':
434
486
  // Conventional liveness check — always 200 if the process
435
487
  // is alive enough to hit the route. No config inspection
@@ -1486,6 +1538,52 @@ export function makeHandler(ctx) {
1486
1538
  }, m.headers || {});
1487
1539
  }
1488
1540
  }
1541
+ case route === 'POST /inbound': {
1542
+ // Generic inbound bridge — a stable, channel-agnostic relay
1543
+ // target so ANY platform (a Discord/WhatsApp/etc. bot the user
1544
+ // runs elsewhere) can forward a message in and get one reply,
1545
+ // without lazyclaw shipping that platform's SDK. Auth-token
1546
+ // gated like every non-gateway route; additionally pairing-gated
1547
+ // on senderId when a pairing allowlist is configured.
1548
+ const breach = checkCostCap(metrics, costCap);
1549
+ if (breach) return writeJson(res, 402, { error: 'cost cap exceeded', currency: breach.currency, spent: breach.spent, cap: breach.cap });
1550
+ let body;
1551
+ try { body = await readJson(req); }
1552
+ catch (e) { return writeJson(res, 400, { error: `invalid JSON body: ${e.message}` }); }
1553
+ const text = typeof body.text === 'string' ? body.text.trim() : '';
1554
+ if (!text) return writeJson(res, 400, { error: 'text is required' });
1555
+ const cfg = ctx.readConfig();
1556
+ // Pairing gate: when the operator has paired any senders, the
1557
+ // relay must identify an allowlisted senderId.
1558
+ const allow = Array.isArray(cfg.pairing) ? cfg.pairing.map((p) => String(p && p.id)) : [];
1559
+ if (allow.length > 0) {
1560
+ const sender = String(body.senderId || '');
1561
+ if (!sender || !allow.includes(sender)) return writeJson(res, 403, { error: 'sender not paired' });
1562
+ }
1563
+ const provName = body.provider || cfg.provider || 'mock';
1564
+ const resolved = resolveProvider(body, provName, cachedByName, logger);
1565
+ if (resolved.error) return writeJson(res, 400, { error: resolved.error });
1566
+ let acc = '';
1567
+ let inboundUsage = null;
1568
+ try {
1569
+ for await (const chunk of resolved.provider.sendMessage(
1570
+ [{ role: 'user', content: text }],
1571
+ { apiKey: cfg['api-key'], model: body.model || cfg.model, onUsage: (u) => { inboundUsage = u; } },
1572
+ )) acc += chunk;
1573
+ } catch (err) {
1574
+ const m = statusForProviderError(err);
1575
+ return writeJson(res, m.status, { error: err?.message || String(err), code: err?.code || null }, m.headers || {});
1576
+ }
1577
+ // Feed the running spend total so the cost cap can actually trip
1578
+ // on /inbound traffic (mirrors POST /agent / POST /chat).
1579
+ if (inboundUsage && cfg.rates) {
1580
+ try {
1581
+ const c = costFromUsage({ provider: provName, model: body.model || cfg.model, usage: inboundUsage }, cfg.rates);
1582
+ if (c) accumulateMetricsFromCost(metrics, inboundUsage, c);
1583
+ } catch { /* cost is best-effort; never block a reply on it */ }
1584
+ }
1585
+ return writeJson(res, 200, { reply: acc, threadId: body.threadId || null });
1586
+ }
1489
1587
  case route === 'POST /agent': {
1490
1588
  const breach = checkCostCap(metrics, costCap);
1491
1589
  if (breach) {