lazyclaw 4.2.2 → 5.0.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.
Files changed (67) hide show
  1. package/README.ko.md +44 -0
  2. package/README.md +172 -353
  3. package/agents.mjs +19 -3
  4. package/channels/handoff.mjs +41 -0
  5. package/channels/loader.mjs +124 -0
  6. package/channels/matrix.mjs +417 -0
  7. package/channels/telegram.mjs +362 -0
  8. package/channels/threads.mjs +116 -0
  9. package/cli.mjs +730 -27
  10. package/daemon.mjs +111 -0
  11. package/gateway/device_auth.mjs +664 -0
  12. package/gateway/http_gateway.mjs +304 -0
  13. package/mas/agent_memory.mjs +35 -34
  14. package/mas/agent_turn.mjs +30 -1
  15. package/mas/confidence.mjs +108 -0
  16. package/mas/index_db.mjs +242 -0
  17. package/mas/mention_router.mjs +75 -4
  18. package/mas/nudge.mjs +97 -0
  19. package/mas/prompt_stack.mjs +80 -0
  20. package/mas/provider_adapters.mjs +83 -0
  21. package/mas/redact.mjs +46 -0
  22. package/mas/skill_synth.mjs +331 -0
  23. package/mas/tool_runner.mjs +19 -48
  24. package/mas/tools/browser.mjs +77 -0
  25. package/mas/tools/clarify.mjs +36 -0
  26. package/mas/tools/coding.mjs +109 -0
  27. package/mas/tools/delegation.mjs +53 -0
  28. package/mas/tools/edit.mjs +36 -0
  29. package/mas/tools/git.mjs +110 -0
  30. package/mas/tools/ha.mjs +34 -0
  31. package/mas/tools/learning.mjs +168 -0
  32. package/mas/tools/media.mjs +105 -0
  33. package/mas/tools/os.mjs +152 -0
  34. package/mas/tools/patch.mjs +91 -0
  35. package/mas/tools/recall.mjs +103 -0
  36. package/mas/tools/registry.mjs +93 -0
  37. package/mas/tools/scheduling.mjs +62 -0
  38. package/mas/tools/skill_view.mjs +43 -0
  39. package/mas/tools/web.mjs +137 -0
  40. package/mas/toolsets.mjs +64 -0
  41. package/mas/trajectory_export.mjs +169 -0
  42. package/mas/trajectory_store.mjs +179 -0
  43. package/mas/user_modeler.mjs +108 -0
  44. package/package.json +22 -3
  45. package/providers/codex_cli.mjs +200 -0
  46. package/providers/gemini_cli.mjs +179 -0
  47. package/providers/registry.mjs +61 -1
  48. package/sandbox/base.mjs +82 -0
  49. package/sandbox/confiners/bubblewrap.mjs +21 -0
  50. package/sandbox/confiners/firejail.mjs +16 -0
  51. package/sandbox/confiners/landlock.mjs +14 -0
  52. package/sandbox/confiners/seatbelt.mjs +28 -0
  53. package/sandbox/daytona.mjs +37 -0
  54. package/sandbox/docker.mjs +91 -0
  55. package/sandbox/index.mjs +67 -0
  56. package/sandbox/local.mjs +59 -0
  57. package/sandbox/modal.mjs +53 -0
  58. package/sandbox/singularity.mjs +39 -0
  59. package/sandbox/ssh.mjs +56 -0
  60. package/sandbox.mjs +11 -127
  61. package/scripts/hermes-import.mjs +111 -0
  62. package/scripts/migrate-v5.mjs +342 -0
  63. package/scripts/openclaw-import.mjs +71 -0
  64. package/sessions.mjs +20 -1
  65. package/skills.mjs +101 -8
  66. package/skills_curator.mjs +323 -0
  67. package/workspace.mjs +18 -3
package/cli.mjs CHANGED
@@ -4,6 +4,10 @@ import path from 'node:path';
4
4
  import fs from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import { pathToFileURL } from 'node:url';
7
+ // sandbox subcommands — list/test/add/use (Phase D).
8
+ import { resolveSandbox, listBackends } from './sandbox/index.mjs';
9
+ // Phase G: defaultConfigDir for personality subcommand (spec §9, decision C7).
10
+ import { defaultConfigDir as _persDefaultCfg } from './memory.mjs';
7
11
 
8
12
  async function loadEngine() {
9
13
  return import('./workflow/persistent.mjs');
@@ -56,6 +60,17 @@ function _resolveAuthKey(cfg, provider) {
56
60
  return cfg['api-key'] || '';
57
61
  }
58
62
 
63
+ // Per-provider base-URL override (used by tests + private gateways).
64
+ // Single source of truth — the reflect / skill-synth / task-tick paths
65
+ // all resolve through here so a new provider's env var lands in one spot.
66
+ function _resolveBaseUrl(provider) {
67
+ return {
68
+ anthropic: process.env.LAZYCLAW_ANTHROPIC_BASE_URL,
69
+ openai: process.env.LAZYCLAW_OPENAI_BASE_URL,
70
+ gemini: process.env.LAZYCLAW_GEMINI_BASE_URL,
71
+ }[provider] || undefined;
72
+ }
73
+
59
74
  async function importWorkflow(file) {
60
75
  const abs = path.resolve(file);
61
76
  const url = pathToFileURL(abs).href;
@@ -628,6 +643,66 @@ async function cmdResume(sessionId, file, opts = {}) {
628
643
  }
629
644
  }
630
645
 
646
+ // --- Phase G: personality subcommand (spec §9, decision C7) -------------
647
+ async function cmdPersonality(sub, a, b) {
648
+ const cfgDir = process.env.LAZYCLAW_CONFIG_DIR || _persDefaultCfg();
649
+ const dir = path.join(cfgDir, 'personalities');
650
+ fs.mkdirSync(dir, { recursive: true });
651
+
652
+ if (!sub || sub === 'list') {
653
+ const names = fs.existsSync(dir)
654
+ ? fs.readdirSync(dir).filter(f => f.endsWith('.md')).map(f => f.slice(0, -3))
655
+ : [];
656
+ if (!names.length) { console.log('No personalities installed'); return 0; }
657
+ for (const n of names.sort()) console.log(n);
658
+ return 0;
659
+ }
660
+
661
+ if (sub === 'show') {
662
+ if (!a) { console.error('Usage: lazyclaw personality show <name>'); return 2; }
663
+ const p = path.join(dir, `${a}.md`);
664
+ if (!fs.existsSync(p)) { console.error(`personality not found: ${a}`); return 1; }
665
+ process.stdout.write(fs.readFileSync(p, 'utf8'));
666
+ return 0;
667
+ }
668
+
669
+ if (sub === 'install') {
670
+ if (!a || !b) { console.error('Usage: lazyclaw personality install <name> <file>'); return 2; }
671
+ const dst = path.join(dir, `${a}.md`);
672
+ if (fs.existsSync(dst)) { console.error(`personality already installed: ${a}`); return 1; }
673
+ if (!fs.existsSync(b)) { console.error(`source file not found: ${b}`); return 1; }
674
+ fs.writeFileSync(dst, fs.readFileSync(b, 'utf8'));
675
+ console.log(`installed ${a}`);
676
+ return 0;
677
+ }
678
+
679
+ if (sub === 'remove') {
680
+ if (!a) { console.error('Usage: lazyclaw personality remove <name>'); return 2; }
681
+ const p = path.join(dir, `${a}.md`);
682
+ if (!fs.existsSync(p)) { console.error(`personality not installed: ${a}`); return 1; }
683
+ fs.unlinkSync(p);
684
+ console.log(`removed ${a}`);
685
+ return 0;
686
+ }
687
+
688
+ if (sub === 'use') {
689
+ if (!a) { console.error('Usage: lazyclaw personality use <name>'); return 2; }
690
+ const p = path.join(dir, `${a}.md`);
691
+ if (!fs.existsSync(p)) { console.error(`personality not installed: ${a}`); return 1; }
692
+ const cfgPath = path.join(cfgDir, 'config.json');
693
+ let cfg = {};
694
+ try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch {}
695
+ cfg.persona = { ...(cfg.persona || {}), personality: a };
696
+ fs.mkdirSync(cfgDir, { recursive: true });
697
+ fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
698
+ console.log(`active personality: ${a}`);
699
+ return 0;
700
+ }
701
+
702
+ console.error(`Unknown personality subcommand: ${sub}`);
703
+ return 2;
704
+ }
705
+
631
706
  async function cmdConfigEdit() {
632
707
  // Open config.json in $EDITOR (or sensible default), then validate
633
708
  // the result before letting the user walk away believing the edit
@@ -674,10 +749,10 @@ function cmdConfigSet(key, value) {
674
749
  function applyOnboardConfig(currentCfg, flags) {
675
750
  // Honors the OpenClaw-style unified provider/model string ("anthropic/claude-opus-4-7")
676
751
  // by splitting it, but explicit --provider always wins.
677
- const { parseProviderModel } = require_registry_sync();
752
+ const { parseSlashProviderModel } = require_registry_sync();
678
753
  const next = { ...currentCfg };
679
754
  if (flags.model) {
680
- const parsed = parseProviderModel(flags.model);
755
+ const parsed = parseSlashProviderModel(flags.model);
681
756
  if (parsed.provider && !flags.provider) next.provider = parsed.provider;
682
757
  next.model = parsed.model || flags.model;
683
758
  }
@@ -914,6 +989,8 @@ const SUBCOMMANDS = [
914
989
  'daemon', 'version', 'completion', 'help',
915
990
  'export', 'import',
916
991
  'rates',
992
+ // v5.0 — sandbox 6-backend (Phase D)
993
+ 'sandbox',
917
994
  // OpenClaw-parity subsurfaces (v3.93–v3.98)
918
995
  'auth', 'pairing', 'nodes', 'message', 'workspace', 'browse', 'cron',
919
996
  // v3.99.6 — multi-step setup wizard + lazyclaw-only dashboard
@@ -924,20 +1001,31 @@ const SUBCOMMANDS = [
924
1001
  'loop', 'loops', 'goal', 'memory',
925
1002
  // v4.0.0 — Slack Socket Mode listener (inbound DM / @-mention)
926
1003
  'slack',
1004
+ // v4.3.0 — Telegram long-poll listener (zero-install mobile control)
1005
+ 'telegram',
1006
+ // v4.3.0 — Matrix /sync long-poll listener
1007
+ 'matrix',
1008
+ // v5.0 — channels plugin loader (Phase F)
1009
+ 'channels',
927
1010
  // v4.1.0 — multi-agent slack system (Phase 9+)
928
1011
  'agent', 'team', 'task',
1012
+ // v5.0 Phase G — persona compose + cross-tool import (spec §9, §10)
1013
+ 'personality', 'migrate', 'hermes', 'openclaw',
1014
+ // v5.0 Phase H1 — trajectory exporter (spec §2.7)
1015
+ 'trajectories',
929
1016
  ];
930
1017
 
931
1018
  const SUBCOMMAND_SUBS = {
932
1019
  config: ['get', 'set', 'list', 'delete', 'unset', 'path', 'edit', 'validate'],
933
1020
  sessions: ['list', 'show', 'clear', 'export', 'search'],
934
- skills: ['list', 'show', 'install', 'remove', 'search'],
1021
+ skills: ['list', 'show', 'install', 'remove', 'search', 'curate', 'classify'],
935
1022
  providers: ['list', 'info', 'test', 'add', 'remove', 'models'],
936
1023
  rates: ['list', 'set', 'delete', 'shape', 'validate', 'copy'],
1024
+ sandbox: ['list', 'test', 'add', 'use'],
937
1025
  completion: ['bash', 'zsh'],
938
1026
  auth: ['list', 'add', 'remove', 'use', 'rotate'],
939
1027
  pairing: ['list', 'add', 'remove'],
940
- nodes: ['list', 'register', 'remove'],
1028
+ nodes: ['list', 'register', 'remove', 'pending', 'approve', 'revoke', 'devices'],
941
1029
  message: ['list', 'add', 'remove', 'send'],
942
1030
  workspace: ['list', 'init', 'show', 'remove', 'path'],
943
1031
  cron: ['list', 'add', 'remove', 'show', 'sync', 'run'],
@@ -946,9 +1034,12 @@ const SUBCOMMAND_SUBS = {
946
1034
  goal: ['add', 'list', 'show', 'close', 'switch', 'tick', 'channel'],
947
1035
  memory: ['show', 'dream', 'edit'],
948
1036
  slack: ['listen'],
1037
+ telegram: ['listen'],
1038
+ matrix: ['listen'],
949
1039
  agent: ['add', 'list', 'show', 'edit', 'remove'],
950
1040
  team: ['add', 'list', 'show', 'edit', 'remove'],
951
1041
  task: ['start', 'list', 'show', 'abandon', 'done', 'remove'],
1042
+ trajectories: ['export'],
952
1043
  };
953
1044
 
954
1045
  function bashCompletion() {
@@ -1197,6 +1288,7 @@ const HELP_DETAILS = {
1197
1288
  export: 'Usage: lazyclaw export [--include-secrets] [--include-sessions] > bundle.json\n --include-secrets keeps the raw api-key in the bundle (default redacts it).\n --include-sessions adds full turn content (default keeps metadata only).',
1198
1289
  import: 'Usage: lazyclaw import [--from <path>] [--overwrite-skills] [--no-overwrite-config] [--import-sessions]\n Reads JSON from stdin (or --from <path>). Sessions are NEVER overwritten.\n Redacted api-keys (***REDACTED***) are dropped, never written.',
1199
1290
  rates: 'Usage: lazyclaw rates <list [--filter <substr>] [--limit <N>] | set <provider/model> --input <N> --output <N> [--cache-read <N>] [--cache-create <N>] [--currency USD] | delete <key> | shape | validate | copy <src> <dst> [--force]>\n Rates are per million tokens. costFromUsage uses cfg.rates to compute the cost block in /usage and body.cost.\n `list` accepts --filter (case-insensitive key substring) and --limit (post-filter cap), same shape sessions/skills/workflows lists use.\n `shape` prints the reference template (zero-filled) you can copy into config.\n `validate` checks the cfg.rates shape: required fields, non-negative numbers, known providers (warn-only).\n `copy` clones an existing card to a new key (use when a new model launches at the same price as an old one).',
1291
+ sandbox: 'Usage: lazyclaw sandbox <list|test|add|use> [args]\n list show 6 backends (local, docker, ssh, singularity, modal, daytona)\n test <kind> run echo through the backend (or argv-shape check for remote)\n add <name> --kind <kind> [--image|--host|--user|--workspace|--app|--confiner ...]\n use <profile> set the profile as cfg.sandbox.default',
1200
1292
  auth: 'Usage: lazyclaw auth <list <provider> | add <provider> <key> [--label <name>] | remove <provider> <label> | use <provider> <label> | rotate <provider>>\n Multiple keys per provider for rate-limit rotation. The active label is sent on every chat / agent call.\n `rotate` advances the cursor to the next label; pair with a 429 hook for auto-failover.',
1201
1293
  pairing: 'Usage: lazyclaw pairing <list | add <id> [--label <name>] | remove <id>>\n Sender allowlist for the messaging surface. Inbound senders not on this list are rejected.\n Sender ids are opaque per-channel: Slack member id, Discord user id, phone number for SMS, etc.',
1202
1294
  nodes: 'Usage: lazyclaw nodes <list | register <id> [--platform macos|ios|android|web|cli] [--label <name>] | remove <id>>\n Companion device registration table. CLI only — the actual mobile / menu-bar apps are out of scope here.\n Platform is free-form lower-case; future surfaces (iOS / Android nodes) authenticate against the daemon using these ids.',
@@ -2283,6 +2375,38 @@ async function cmdChat(flags = {}) {
2283
2375
 
2284
2376
  // Top-of-session banner so the user can see at a glance what they're
2285
2377
  // talking to. Cheap (no provider call) and TTY-only.
2378
+ // v5 ink splash + REPL when stdin is a real TTY and the user has not
2379
+ // opted out via LAZYCLAW_NO_INK=1. Non-TTY pipelines and the opt-out
2380
+ // env var fall through to the v4 figlet + readline path unchanged.
2381
+ const __useInkSplash = process.stdout.isTTY && !process.env.LAZYCLAW_NO_INK;
2382
+ if (__useInkSplash) {
2383
+ try {
2384
+ const React = (await import('react')).default;
2385
+ const { render } = await import('ink');
2386
+ const { ReplApp } = await import('./tui/repl.mjs');
2387
+ const { renderSplashToString } = await import('./tui/splash.mjs');
2388
+ // narrow-terminal fallback: <60 cols falls back to v4
2389
+ if ((process.stdout.columns || 80) < 60) throw new Error('narrow-terminal');
2390
+ const splashProps = {
2391
+ provider: activeProvName, model: activeModel,
2392
+ trainer: {}, sessionId: flags.session || '',
2393
+ cwd: process.cwd(),
2394
+ tools: [], skills: [],
2395
+ };
2396
+ void renderSplashToString; // surfaced for tests; runtime uses <Splash/>
2397
+ const ink = render(/* @__PURE__ */ React.createElement(ReplApp, {
2398
+ splashProps,
2399
+ runTurn: async (_text, _signal) => { void _text; void _signal; },
2400
+ }));
2401
+ await ink.waitUntilExit();
2402
+ return;
2403
+ } catch (e) {
2404
+ // Fall through to legacy path on any ink failure (missing import,
2405
+ // narrow terminal, sandboxed stdout).
2406
+ if (process.env.LAZYCLAW_DEBUG) console.error('[ink] fallback:', e.message);
2407
+ }
2408
+ }
2409
+ // ─── legacy v4 path (unchanged) ─────────────────────────────────
2286
2410
  _printChatBanner(activeProvName, activeModel, readVersionFromRepo());
2287
2411
 
2288
2412
  const readline = await import('node:readline');
@@ -2464,8 +2588,8 @@ async function cmdChat(flags = {}) {
2464
2588
  }
2465
2589
  // Honor unified provider/model: `/model anthropic/claude-opus-4-7`
2466
2590
  // splits and switches both.
2467
- const { parseProviderModel } = _registryMod;
2468
- const parsed = parseProviderModel(arg);
2591
+ const { parseSlashProviderModel } = _registryMod;
2592
+ const parsed = parseSlashProviderModel(arg);
2469
2593
  if (parsed.provider) {
2470
2594
  const next = lookupProv(parsed.provider);
2471
2595
  if (!next) {
@@ -2925,6 +3049,53 @@ async function cmdChat(flags = {}) {
2925
3049
  }
2926
3050
  return true;
2927
3051
  }
3052
+ case '/handoff': {
3053
+ // /handoff <target-channel> <externalId> [--note=...] — migrates the
3054
+ // active thread (bound to replState.channel / replState.externalId)
3055
+ // to a new channel and posts transition stubs on both sides. In the
3056
+ // local-only chat REPL there is no bound channel, so we surface a
3057
+ // clear error and stay in the REPL (acceptance test §F).
3058
+ const parts = line.trim().split(/\s+/).slice(1);
3059
+ if (parts.length < 2) {
3060
+ process.stderr.write('usage: /handoff <target-channel> <externalId> [--note=...]\n');
3061
+ return true;
3062
+ }
3063
+ const target = parts[0];
3064
+ const externalId = parts[1];
3065
+ const note = (parts.find(p => p.startsWith('--note=')) || '').slice(7);
3066
+ try {
3067
+ const { openThreads } = await import('./channels/threads.mjs');
3068
+ const { runHandoff } = await import('./channels/handoff.mjs');
3069
+ const threads = openThreads(cfgDir);
3070
+ const replState = globalThis.__lazyclawReplState || {};
3071
+ const cur = replState.channel && replState.externalId
3072
+ ? threads.findByExternal(replState.channel, replState.externalId)
3073
+ : null;
3074
+ if (!cur) {
3075
+ process.stderr.write(
3076
+ `handoff: no thread bound to ${replState.channel || '(none)'}:${replState.externalId || '(none)'}\n`,
3077
+ );
3078
+ return true;
3079
+ }
3080
+ const next = await runHandoff({
3081
+ threads, channels: replState.channels || {},
3082
+ threadId: cur.threadId, target, externalId, note,
3083
+ });
3084
+ process.stdout.write(`handoff -> ${next.channel}:${next.externalId} (session ${next.sessionId})\n`);
3085
+ replState.channel = next.channel;
3086
+ replState.externalId = next.externalId;
3087
+ } catch (e) {
3088
+ process.stderr.write(`handoff failed: ${e.code || 'ERR'}: ${e.message}\n`);
3089
+ }
3090
+ return true;
3091
+ }
3092
+ case '/personality': {
3093
+ // Phase G: thin slash wrapper over cmdPersonality.
3094
+ const tail = line.slice('/personality'.length).trim();
3095
+ const parts = tail.split(/\s+/).filter(Boolean);
3096
+ await cmdPersonality(parts[0] || 'list', parts[1], parts[2]);
3097
+ return true;
3098
+ }
2928
3099
  case '/exit': {
2929
3100
  return 'EXIT';
2930
3101
  }
@@ -3577,8 +3748,51 @@ async function cmdNodes(sub, positional, flags = {}) {
3577
3748
  console.log(JSON.stringify({ ok: true, removed: id }));
3578
3749
  return;
3579
3750
  }
3751
+ // Device-gateway pairing (Phase 27) — distinct from the config-based
3752
+ // `nodes register` table above. These drive the Ed25519 PairingStore
3753
+ // a companion node authenticates against via `lazyclaw daemon`.
3754
+ case 'pending': {
3755
+ const { PairingStore } = await import('./gateway/device_auth.mjs');
3756
+ const store = new PairingStore(path.dirname(configPath()));
3757
+ console.log(JSON.stringify(store.pending(), null, 2));
3758
+ return;
3759
+ }
3760
+ case 'devices': {
3761
+ const { PairingStore } = await import('./gateway/device_auth.mjs');
3762
+ const store = new PairingStore(path.dirname(configPath()));
3763
+ console.log(JSON.stringify(store.devicesList(), null, 2));
3764
+ return;
3765
+ }
3766
+ case 'approve': {
3767
+ const requestId = positional[0];
3768
+ if (!requestId) {
3769
+ console.error('Usage: lazyclaw nodes approve <requestId> (see `lazyclaw nodes pending`)');
3770
+ process.exit(2);
3771
+ }
3772
+ const { PairingStore } = await import('./gateway/device_auth.mjs');
3773
+ const store = new PairingStore(path.dirname(configPath()));
3774
+ try {
3775
+ const { deviceId } = store.approve(requestId);
3776
+ // The token is intentionally NOT printed — the device receives its
3777
+ // rotated token on its next /gateway/connect, so it never has to
3778
+ // pass through a terminal / shell history.
3779
+ console.log(JSON.stringify({ ok: true, approved: requestId, deviceId, note: 'device receives its token on next /gateway/connect' }));
3780
+ } catch (e) { console.error(`error: ${e.message}`); process.exit(1); }
3781
+ return;
3782
+ }
3783
+ case 'revoke': {
3784
+ const deviceId = positional[0];
3785
+ if (!deviceId) {
3786
+ console.error('Usage: lazyclaw nodes revoke <deviceId>');
3787
+ process.exit(2);
3788
+ }
3789
+ const { PairingStore } = await import('./gateway/device_auth.mjs');
3790
+ const store = new PairingStore(path.dirname(configPath()));
3791
+ console.log(JSON.stringify(store.revoke(deviceId)));
3792
+ return;
3793
+ }
3580
3794
  default:
3581
- console.error('Usage: lazyclaw nodes <list|register|remove> ...');
3795
+ console.error('Usage: lazyclaw nodes <list|register|remove|pending|approve <requestId>|revoke <deviceId>|devices> ...');
3582
3796
  process.exit(2);
3583
3797
  }
3584
3798
  }
@@ -4382,7 +4596,7 @@ async function cmdMemory(sub, positional, flags = {}) {
4382
4596
  }
4383
4597
  }
4384
4598
 
4385
- const AGENT_REG_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete', 'memory', 'reflect']);
4599
+ const AGENT_REG_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete', 'memory', 'reflect', 'skill-synth']);
4386
4600
  const TEAM_SUBS = new Set(['add', 'list', 'show', 'edit', 'remove', 'rm', 'delete']);
4387
4601
  const TASK_SUBS = new Set(['start', 'tick', 'list', 'show', 'abandon', 'done', 'transcript', 'remove', 'rm', 'delete']);
4388
4602
 
@@ -4510,14 +4724,36 @@ async function cmdTask(sub, positional, flags = {}) {
4510
4724
  const cfg = readConfig();
4511
4725
  const leadAgent = agentsById[team.lead];
4512
4726
  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;
4727
+ // Per-provider base-url override (tests point this at a local mock;
4728
+ // production leaves it unset for the built-in default).
4729
+ const baseUrl = _resolveBaseUrl(leadAgent.provider);
4730
+ // --approve-url turns on remote human-in-the-loop approval for the
4731
+ // sensitive tools (bash/write): each such call long-polls a running
4732
+ // daemon's `POST /exec/request`, which broadcasts to paired devices
4733
+ // over the gateway SSE and resolves when one approves. Fail-closed —
4734
+ // any endpoint error denies the call. Omit the flag → ungated (the
4735
+ // historical behavior).
4736
+ let approve;
4737
+ if (flags['approve-url']) {
4738
+ const approveUrl = String(flags['approve-url']).replace(/\/$/, '');
4739
+ const approveToken = flags['approve-token'] ? String(flags['approve-token']) : '';
4740
+ const approveTimeoutMs = flags['approve-timeout'] ? parseInt(flags['approve-timeout'], 10) : 120000;
4741
+ approve = async ({ tool, args, agent }) => {
4742
+ const summary = `${tool}: ${typeof args === 'object' ? JSON.stringify(args) : String(args)}`.slice(0, 400);
4743
+ try {
4744
+ const r = await fetch(`${approveUrl}/exec/request`, {
4745
+ method: 'POST',
4746
+ headers: { 'content-type': 'application/json', ...(approveToken ? { authorization: `Bearer ${approveToken}` } : {}) },
4747
+ body: JSON.stringify({ tool, agentId: agent, summary, timeoutMs: approveTimeoutMs }),
4748
+ });
4749
+ if (!r.ok) return { approved: false, reason: `approval endpoint HTTP ${r.status}` };
4750
+ const j = await r.json();
4751
+ return { approved: !!j.approved, reason: j.reason || (j.approved ? 'approved' : 'denied') };
4752
+ } catch (err) {
4753
+ return { approved: false, reason: `approval request failed: ${err?.message || err}` };
4754
+ }
4755
+ };
4756
+ }
4521
4757
  try {
4522
4758
  const result = await router.runTaskTurn({
4523
4759
  task, team, agentsById,
@@ -4527,6 +4763,7 @@ async function cmdTask(sub, positional, flags = {}) {
4527
4763
  baseUrl,
4528
4764
  logger: (line) => process.stderr.write(line),
4529
4765
  maxAgentTurns: flags['max-turns'] ? parseInt(flags['max-turns'], 10) : undefined,
4766
+ approve,
4530
4767
  });
4531
4768
  emitJson({ id: result.task.id, status: result.task.status, iterations: result.iterations, stoppedBy: result.stoppedBy });
4532
4769
  } catch (err) {
@@ -4685,7 +4922,7 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
4685
4922
  return;
4686
4923
  }
4687
4924
  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); }
4925
+ 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
4926
  const tools = agentsMod.parseToolsFlag(flags.tools);
4690
4927
  try {
4691
4928
  const a = agentsMod.registerAgent({
@@ -4696,6 +4933,7 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
4696
4933
  model: flags.model || '',
4697
4934
  tools: tools === null ? undefined : tools,
4698
4935
  tags: agentsMod.parseToolsFlag(flags.tags) || [],
4936
+ skillWrite: flags['skill-write'],
4699
4937
  }, cfgDir);
4700
4938
  emitJson(a);
4701
4939
  } catch (err) {
@@ -4712,7 +4950,7 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
4712
4950
  return;
4713
4951
  }
4714
4952
  case 'edit': {
4715
- if (!name) { console.error('Usage: lazyclaw agent edit <name> [--role "..."] [--provider X] [--model Y] [--display "..."] [--tools ...]'); process.exit(2); }
4953
+ 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
4954
  const patch = {};
4717
4955
  if (flags.role !== undefined) patch.role = String(flags.role);
4718
4956
  if (flags.provider !== undefined) patch.provider = String(flags.provider);
@@ -4721,6 +4959,8 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
4721
4959
  if (flags['display-name'] !== undefined) patch.displayName = String(flags['display-name']);
4722
4960
  if (flags.tools !== undefined) patch.tools = agentsMod.parseToolsFlag(flags.tools);
4723
4961
  if (flags.tags !== undefined) patch.tags = agentsMod.parseToolsFlag(flags.tags);
4962
+ if (flags['skill-write'] !== undefined) patch.skillWrite = String(flags['skill-write']);
4963
+ if (flags['memory-write'] !== undefined) patch.memoryWrite = String(flags['memory-write']);
4724
4964
  if (Object.keys(patch).length === 0) {
4725
4965
  console.error('agent edit: no fields to update');
4726
4966
  process.exit(2);
@@ -4796,11 +5036,7 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
4796
5036
  try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
4797
5037
  const cfg = readConfig();
4798
5038
  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;
5039
+ const baseUrl = _resolveBaseUrl(a.provider);
4804
5040
  try {
4805
5041
  const body = await memMod.reflectOnce({ agent: a, task, apiKey, baseUrl });
4806
5042
  if (!body || !body.trim()) {
@@ -4817,8 +5053,50 @@ async function cmdAgentRegistry(sub, positional, flags = {}) {
4817
5053
  }
4818
5054
  return;
4819
5055
  }
5056
+ case 'skill-synth': {
5057
+ const aname = positional[0];
5058
+ const taskId = flags.task || positional[1];
5059
+ if (!aname || !taskId) {
5060
+ console.error('Usage: lazyclaw agent skill-synth <name> --task <id> [--dry-run]');
5061
+ process.exit(2);
5062
+ }
5063
+ const tasksMod = await import('./tasks.mjs');
5064
+ const synthMod = await import('./mas/skill_synth.mjs');
5065
+ const skillsMod = await import('./skills.mjs');
5066
+ const a = agentsMod.getAgent(aname, cfgDir);
5067
+ if (!a) { console.error(`agent skill-synth: no agent "${aname}"`); process.exit(2); }
5068
+ const task = tasksMod.getTask(taskId, cfgDir);
5069
+ if (!task) { console.error(`agent skill-synth: no task "${taskId}"`); process.exit(2); }
5070
+ try { _loadDotenvIfAny(cfgDir); } catch { /* best-effort */ }
5071
+ const cfg = readConfig();
5072
+ const apiKey = _resolveAuthKey(cfg, a.provider);
5073
+ const baseUrl = _resolveBaseUrl(a.provider);
5074
+ try {
5075
+ const result = await synthMod.synthesizeSkill({ agent: a, task, apiKey, baseUrl });
5076
+ if (!result) {
5077
+ process.stderr.write('skill synthesis produced nothing worth saving\n');
5078
+ return;
5079
+ }
5080
+ if (!flags['dry-run']) {
5081
+ // installSynthesized reserves a collision-free name (never
5082
+ // clobbers a human-authored skill) and version-bumps when it
5083
+ // improves its own prior skill.
5084
+ const installed = synthMod.installSynthesized(
5085
+ { name: result.name, description: result.description, body: result.body, sourceTask: task.id },
5086
+ cfgDir,
5087
+ );
5088
+ emitJson({ skill: installed.skill, description: result.description, version: installed.version, path: installed.path });
5089
+ } else {
5090
+ process.stdout.write(result.doc + (result.doc.endsWith('\n') ? '' : '\n'));
5091
+ }
5092
+ } catch (err) {
5093
+ console.error(`agent skill-synth: ${err?.message || err}`);
5094
+ process.exit(2);
5095
+ }
5096
+ return;
5097
+ }
4820
5098
  default:
4821
- console.error('Usage: lazyclaw agent <add|list|show|edit|remove|memory|reflect> ...');
5099
+ console.error('Usage: lazyclaw agent <add|list|show|edit|remove|memory|reflect|skill-synth> ...');
4822
5100
  process.exit(2);
4823
5101
  }
4824
5102
  }
@@ -4929,6 +5207,165 @@ async function cmdSlack(sub, positional, flags = {}) {
4929
5207
  });
4930
5208
  }
4931
5209
 
5210
+ // `lazyclaw telegram listen` — zero-install mobile control surface.
5211
+ // Long-polls the Telegram Bot API (no public URL / webhook needed) and
5212
+ // pipes each inbound message through the active provider, replying in
5213
+ // the same chat. Mirrors `slack listen`. Access is gated by the existing
5214
+ // `pairing` allowlist (Telegram numeric user ids); an empty allowlist
5215
+ // means "reply to anyone who can reach the bot".
5216
+ async function cmdTelegram(sub, positional, flags = {}) {
5217
+ if (sub !== 'listen') {
5218
+ 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>`.');
5219
+ process.exit(2);
5220
+ }
5221
+ await ensureRegistry();
5222
+ const cfg = readConfig();
5223
+ const cfgDir = path.dirname(configPath());
5224
+
5225
+ const envInfo = _loadDotenvIfAny(cfgDir);
5226
+ process.stderr.write(`[telegram] .env: ${envInfo.loaded} keys loaded from ${envInfo.path}\n`);
5227
+
5228
+ const provName = flags.provider || cfg.provider || 'mock';
5229
+ const prov = _registryMod.PROVIDERS[provName];
5230
+ if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
5231
+ const model = flags.model || cfg.model;
5232
+
5233
+ const threadMsgs = new Map();
5234
+ const MAX_TURNS = 20;
5235
+
5236
+ const handler = async ({ threadId, text }) => {
5237
+ const cleaned = String(text || '').trim();
5238
+ if (!cleaned) { process.stderr.write('[telegram] dropping empty inbound\n'); return null; }
5239
+ const msgs = threadMsgs.get(threadId) || [];
5240
+ msgs.push({ role: 'user', content: cleaned });
5241
+ let acc = '';
5242
+ try {
5243
+ for await (const chunk of prov.sendMessage(msgs, { apiKey: _resolveAuthKey(cfg, provName), model })) acc += chunk;
5244
+ } catch (err) {
5245
+ msgs.pop();
5246
+ const why = err?.message || String(err);
5247
+ process.stderr.write(`[telegram] provider error: ${why}\n`);
5248
+ return `(provider error: ${why})`;
5249
+ }
5250
+ msgs.push({ role: 'assistant', content: acc });
5251
+ if (msgs.length > MAX_TURNS) msgs.splice(0, msgs.length - MAX_TURNS);
5252
+ threadMsgs.set(threadId, msgs);
5253
+ if (!acc.trim()) { process.stderr.write('[telegram] provider returned empty text — not posting\n'); return null; }
5254
+ return acc;
5255
+ };
5256
+
5257
+ // The pairing allowlist doubles as the Telegram sender allowlist.
5258
+ const allowlist = (cfg.pairing || []).map((p) => String(p.id));
5259
+ const { TelegramChannel } = await import('./channels/telegram.mjs');
5260
+ let ch;
5261
+ try {
5262
+ ch = new TelegramChannel({ allowlist: allowlist.length ? allowlist : null });
5263
+ } catch (err) {
5264
+ console.error(`telegram: ${err?.message || err}`);
5265
+ process.exit(2);
5266
+ }
5267
+ process.stderr.write(`[telegram] provider=${provName} model=${model || '(default)'} allowlist=${allowlist.length || 'open'}\n`);
5268
+ try {
5269
+ await ch.start(handler, { poll: true, logger: (line) => process.stderr.write(line) });
5270
+ } catch (err) {
5271
+ if (err?.code === 'TELEGRAM_MISSING_TOKEN') {
5272
+ console.error('telegram: TELEGRAM_BOT_TOKEN not set');
5273
+ console.error(`hint: add TELEGRAM_BOT_TOKEN=... to ${path.join(cfgDir, '.env')}`);
5274
+ } else {
5275
+ console.error(`telegram: ${err?.message || err}`);
5276
+ }
5277
+ process.exit(2);
5278
+ }
5279
+ process.stderr.write(`[telegram] listening. Ctrl-C to stop.\n`);
5280
+
5281
+ await new Promise((resolve) => {
5282
+ const onSig = async () => {
5283
+ process.stderr.write(`\n[telegram] shutting down…\n`);
5284
+ try { await ch.stop(); } catch { /* best-effort */ }
5285
+ resolve();
5286
+ };
5287
+ process.once('SIGINT', onSig);
5288
+ process.once('SIGTERM', onSig);
5289
+ });
5290
+ }
5291
+
5292
+ // `lazyclaw matrix listen` — Matrix inbound over the client-server API's
5293
+ // long-poll /sync (no SDK). Mirrors `telegram listen`. Set MATRIX_HOMESERVER
5294
+ // + MATRIX_ACCESS_TOKEN (+ MATRIX_USER_ID for self-filtering) in ~/.lazyclaw/.env.
5295
+ async function cmdMatrix(sub, positional, flags = {}) {
5296
+ if (sub !== 'listen') {
5297
+ 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>`.');
5298
+ process.exit(2);
5299
+ }
5300
+ await ensureRegistry();
5301
+ const cfg = readConfig();
5302
+ const cfgDir = path.dirname(configPath());
5303
+
5304
+ const envInfo = _loadDotenvIfAny(cfgDir);
5305
+ process.stderr.write(`[matrix] .env: ${envInfo.loaded} keys loaded from ${envInfo.path}\n`);
5306
+
5307
+ const provName = flags.provider || cfg.provider || 'mock';
5308
+ const prov = _registryMod.PROVIDERS[provName];
5309
+ if (!prov) { console.error(`unknown provider: ${provName}`); process.exit(2); }
5310
+ const model = flags.model || cfg.model;
5311
+
5312
+ const threadMsgs = new Map();
5313
+ const MAX_TURNS = 20;
5314
+ const handler = async ({ threadId, text }) => {
5315
+ const cleaned = String(text || '').trim();
5316
+ if (!cleaned) { process.stderr.write('[matrix] dropping empty inbound\n'); return null; }
5317
+ const msgs = threadMsgs.get(threadId) || [];
5318
+ msgs.push({ role: 'user', content: cleaned });
5319
+ let acc = '';
5320
+ try {
5321
+ for await (const chunk of prov.sendMessage(msgs, { apiKey: _resolveAuthKey(cfg, provName), model })) acc += chunk;
5322
+ } catch (err) {
5323
+ msgs.pop();
5324
+ const why = err?.message || String(err);
5325
+ process.stderr.write(`[matrix] provider error: ${why}\n`);
5326
+ return `(provider error: ${why})`;
5327
+ }
5328
+ msgs.push({ role: 'assistant', content: acc });
5329
+ if (msgs.length > MAX_TURNS) msgs.splice(0, msgs.length - MAX_TURNS);
5330
+ threadMsgs.set(threadId, msgs);
5331
+ if (!acc.trim()) { process.stderr.write('[matrix] provider returned empty text — not posting\n'); return null; }
5332
+ return acc;
5333
+ };
5334
+
5335
+ const allowlist = (cfg.pairing || []).map((p) => String(p.id));
5336
+ const { MatrixChannel } = await import('./channels/matrix.mjs');
5337
+ let ch;
5338
+ try {
5339
+ ch = new MatrixChannel({ allowlist: allowlist.length ? allowlist : null });
5340
+ } catch (err) {
5341
+ console.error(`matrix: ${err?.message || err}`);
5342
+ process.exit(2);
5343
+ }
5344
+ process.stderr.write(`[matrix] provider=${provName} model=${model || '(default)'} allowlist=${allowlist.length || 'open'}\n`);
5345
+ try {
5346
+ await ch.start(handler, { poll: true, logger: (line) => process.stderr.write(line) });
5347
+ } catch (err) {
5348
+ if (err?.code === 'MATRIX_MISSING_TOKEN' || err?.code === 'MATRIX_MISSING_HOMESERVER') {
5349
+ console.error(`matrix: ${err.message}`);
5350
+ console.error(`hint: set MATRIX_HOMESERVER and MATRIX_ACCESS_TOKEN in ${path.join(cfgDir, '.env')}`);
5351
+ } else {
5352
+ console.error(`matrix: ${err?.message || err}`);
5353
+ }
5354
+ process.exit(2);
5355
+ }
5356
+ process.stderr.write(`[matrix] listening. Ctrl-C to stop.\n`);
5357
+
5358
+ await new Promise((resolve) => {
5359
+ const onSig = async () => {
5360
+ process.stderr.write(`\n[matrix] shutting down…\n`);
5361
+ try { await ch.stop(); } catch { /* best-effort */ }
5362
+ resolve();
5363
+ };
5364
+ process.once('SIGINT', onSig);
5365
+ process.once('SIGTERM', onSig);
5366
+ });
5367
+ }
5368
+
4932
5369
  async function cmdSkills(sub, positional, flags = {}) {
4933
5370
  const skillsMod = await import('./skills.mjs');
4934
5371
  const cfgDir = path.dirname(configPath());
@@ -5120,8 +5557,24 @@ async function cmdSkills(sub, positional, flags = {}) {
5120
5557
  console.log(JSON.stringify({ query, regex: useRegex, matches }, null, 2));
5121
5558
  return;
5122
5559
  }
5560
+ case 'curate': {
5561
+ // Lifecycle sweep: agent-authored skills unused >90d move into
5562
+ // skills/.archive/ (recoverable). Human-authored skills are never
5563
+ // moved. The real clock is injected here; the module stays pure.
5564
+ const curator = await import('./skills_curator.mjs');
5565
+ const r = curator.curate(cfgDir, Date.now());
5566
+ console.log(JSON.stringify(r, null, 2));
5567
+ return;
5568
+ }
5569
+ case 'classify': {
5570
+ const name = positional[0];
5571
+ if (!name) { console.error('Usage: lazyclaw skills classify <name>'); process.exit(2); }
5572
+ const curator = await import('./skills_curator.mjs');
5573
+ console.log(JSON.stringify({ name, state: curator.classify(name, cfgDir, Date.now()), usage: curator.usageOf(name, cfgDir) }, null, 2));
5574
+ return;
5575
+ }
5123
5576
  default:
5124
- console.error('Usage: lazyclaw skills <list|show <name>|install <name> [--from path]|remove <name>|search <query> [--regex]>');
5577
+ console.error('Usage: lazyclaw skills <list|show <name>|install <name> [--from path]|remove <name>|search <query> [--regex]|curate|classify <name>>');
5125
5578
  process.exit(2);
5126
5579
  }
5127
5580
  }
@@ -5662,10 +6115,118 @@ async function cmdSessions(sub, positional, flags = {}) {
5662
6115
  }
5663
6116
  }
5664
6117
 
6118
+ // sandbox subcommands — list/test/add/use (Phase D).
6119
+ async function cmdSandbox(args, flags = {}) {
6120
+ const sub = args[0];
6121
+
6122
+ if (!sub || sub === 'list') {
6123
+ for (const kind of listBackends()) process.stdout.write(`${kind}\n`);
6124
+ return 0;
6125
+ }
6126
+
6127
+ if (sub === 'test') {
6128
+ const name = args[1];
6129
+ if (!name) { process.stderr.write('usage: lazyclaw sandbox test <kind|profile>\n'); return 2; }
6130
+ const cfg = _sandboxLoadConfigOrEmpty();
6131
+ // If `name` looks like a known kind, route to that kind. If it
6132
+ // is not a known kind AND not a profile in cfg, treat as an
6133
+ // unknown identifier and report SANDBOX_BAD_KIND.
6134
+ const isKind = listBackends().includes(name);
6135
+ const profile = cfg.sandbox && cfg.sandbox.profiles && cfg.sandbox.profiles[name];
6136
+ if (!isKind && !profile) {
6137
+ process.stderr.write(`SANDBOX_BAD_KIND: unknown sandbox kind or profile "${name}"\n`);
6138
+ return 1;
6139
+ }
6140
+ let sb;
6141
+ try {
6142
+ const synthCfg = isKind
6143
+ ? { sandbox: { default: name, ...cfg.sandbox } }
6144
+ : cfg;
6145
+ sb = resolveSandbox(synthCfg);
6146
+ } catch (e) {
6147
+ process.stderr.write(`${e.code || 'SANDBOX_ERR'}: ${e.message}\n`); return 1;
6148
+ }
6149
+ if (sb.spec.kind !== 'local' && sb.spec.kind !== 'docker') {
6150
+ // Remote/serverless backends just construct argv in unit tests;
6151
+ // we report "shape-ok" without actually executing.
6152
+ process.stdout.write(`ok ${sb.spec.kind} (argv-shape)\n`);
6153
+ return 0;
6154
+ }
6155
+ const sess = await sb.open();
6156
+ try {
6157
+ const r = await sess.exec(['echo', 'lazyclaw-sandbox-test']);
6158
+ if (r.code !== 0 || !/lazyclaw-sandbox-test/.test(r.stdout)) {
6159
+ process.stderr.write(`fail ${name}: exit=${r.code} stdout=${r.stdout}\n`); return 1;
6160
+ }
6161
+ process.stdout.write(`ok ${name}\n`);
6162
+ return 0;
6163
+ } finally { await sess.close(); }
6164
+ }
6165
+
6166
+ if (sub === 'add') {
6167
+ const name = args[1];
6168
+ if (!name) { process.stderr.write('usage: lazyclaw sandbox add <name> --kind <kind> [...]\n'); return 2; }
6169
+ const opts = {};
6170
+ if (flags.kind) opts.kind = flags.kind;
6171
+ if (flags.image) opts.image = flags.image;
6172
+ if (flags.host) opts.host = flags.host;
6173
+ if (flags.user) opts.user = flags.user;
6174
+ if (flags.workspace) opts.workspace = flags.workspace;
6175
+ if (flags.app) opts.app = flags.app;
6176
+ if (flags.confiner) opts.confiner = flags.confiner;
6177
+ if (!listBackends().includes(opts.kind)) {
6178
+ process.stderr.write(`unknown kind "${opts.kind}"\n`); return 1;
6179
+ }
6180
+ const cfg = _sandboxLoadConfigOrEmpty();
6181
+ cfg.sandbox = cfg.sandbox || {};
6182
+ cfg.sandbox.profiles = cfg.sandbox.profiles || {};
6183
+ cfg.sandbox.profiles[name] = opts;
6184
+ _sandboxSaveConfig(cfg);
6185
+ process.stdout.write(`added profile ${name} (${opts.kind})\n`);
6186
+ return 0;
6187
+ }
6188
+
6189
+ if (sub === 'use') {
6190
+ const name = args[1];
6191
+ if (!name) { process.stderr.write('usage: lazyclaw sandbox use <profile>\n'); return 2; }
6192
+ const cfg = _sandboxLoadConfigOrEmpty();
6193
+ const prof = cfg.sandbox && cfg.sandbox.profiles && cfg.sandbox.profiles[name];
6194
+ if (!prof) { process.stderr.write(`no profile "${name}"\n`); return 1; }
6195
+ cfg.sandbox = cfg.sandbox || {};
6196
+ cfg.sandbox.default = prof.kind;
6197
+ cfg.sandbox[prof.kind] = { ...(cfg.sandbox[prof.kind] || {}), ...prof, kind: undefined };
6198
+ delete cfg.sandbox[prof.kind].kind;
6199
+ _sandboxSaveConfig(cfg);
6200
+ process.stdout.write(`using profile ${name} (${prof.kind})\n`);
6201
+ return 0;
6202
+ }
6203
+
6204
+ process.stderr.write(`unknown subcommand "${sub}". Try: list | test | add | use\n`);
6205
+ return 2;
6206
+ }
6207
+
6208
+ function _sandboxLoadConfigOrEmpty() {
6209
+ const p = process.env.LAZYCLAW_CONFIG || configPath();
6210
+ try {
6211
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
6212
+ } catch { return {}; }
6213
+ }
6214
+
6215
+ function _sandboxSaveConfig(cfg) {
6216
+ const p = process.env.LAZYCLAW_CONFIG || configPath();
6217
+ fs.mkdirSync(path.dirname(p), { recursive: true });
6218
+ fs.writeFileSync(p, JSON.stringify(cfg, null, 2));
6219
+ }
6220
+
5665
6221
  function cmdConfigGet(key) {
5666
6222
  const cfg = readConfig();
5667
- if (key) console.log(JSON.stringify({ key, value: cfg[key] ?? null }));
5668
- else console.log(JSON.stringify(cfg));
6223
+ if (!key) { console.log(JSON.stringify(cfg)); return; }
6224
+ let value = cfg;
6225
+ for (const seg of String(key).split('.')) {
6226
+ if (value && typeof value === 'object' && seg in value) value = value[seg];
6227
+ else { value = null; break; }
6228
+ }
6229
+ console.log(JSON.stringify({ key, value }));
5669
6230
  }
5670
6231
 
5671
6232
  // Structural integrity check across the whole config. Distinct from
@@ -5997,6 +6558,8 @@ async function _dispatchMenuChoice(argv) {
5997
6558
  case 'goal': return await cmdGoal(rest[0], rest.slice(1), {});
5998
6559
  case 'memory': return await cmdMemory(rest[0], rest.slice(1), {});
5999
6560
  case 'slack': return await cmdSlack(rest[0], rest.slice(1), {});
6561
+ case 'telegram': return await cmdTelegram(rest[0], rest.slice(1), {});
6562
+ case 'matrix': return await cmdMatrix(rest[0], rest.slice(1), {});
6000
6563
  case 'team': return await cmdTeam(rest[0], rest.slice(1), {});
6001
6564
  case 'task': return await cmdTask(rest[0], rest.slice(1), {});
6002
6565
  case 'auth': return await cmdAuth(rest[0], rest.slice(1), {});
@@ -6024,6 +6587,8 @@ async function _dispatchMenuChoice(argv) {
6024
6587
  case 'inspect': return await cmdInspect(rest[0], {});
6025
6588
  case 'export': return await cmdExport({});
6026
6589
  case 'version': return await cmdVersion();
6590
+ // Phase G — persona compose subcommand (spec §9, decision C7).
6591
+ case 'personality': return await cmdPersonality(rest[0], rest[1], rest[2]);
6027
6592
  // help <cmd> is the safe fallback for commands that need real
6028
6593
  // arguments (run / resume / clear / validate / graph / daemon /
6029
6594
  // import / completion). Print the usage so the user can re-launch
@@ -6460,6 +7025,105 @@ async function main() {
6460
7025
  }
6461
7026
  break;
6462
7027
  }
7028
+ case 'personality': {
7029
+ // Phase G: persona compose subcommand (spec §9, decision C7).
7030
+ process.exit(await cmdPersonality(rest.positional[0], rest.positional[1], rest.positional[2]));
7031
+ break;
7032
+ }
7033
+ case 'migrate': {
7034
+ // Phase A baseline accepts `lazyclaw migrate v5`; Phase G adds the
7035
+ // bare `lazyclaw migrate` and `lazyclaw migrate rollback` forms.
7036
+ const target = rest.positional[0];
7037
+ if (target === 'rollback') {
7038
+ const mod = await import('./scripts/migrate-v5.mjs');
7039
+ try {
7040
+ const { restoredFrom } = mod.rollback();
7041
+ console.log(`rolled back from ${restoredFrom}`);
7042
+ process.exit(0);
7043
+ } catch (e) {
7044
+ console.error(`migrate failed: ${e.message}`);
7045
+ process.exit(1);
7046
+ }
7047
+ break;
7048
+ }
7049
+ const mod = await import('./scripts/migrate-v5.mjs');
7050
+ // `migrate v5` keeps the Phase-A behaviour (verbose JSON); the
7051
+ // bare `migrate` form uses the Phase-G human summary.
7052
+ if (target === 'v5') {
7053
+ const r = await mod.migrateV5();
7054
+ console.log(JSON.stringify(r, null, 2));
7055
+ process.exit(r.ok ? 0 : 1);
7056
+ }
7057
+ try {
7058
+ const { backupDir } = mod.migrate();
7059
+ console.log(`migrated; backup at ${backupDir}`);
7060
+ process.exit(0);
7061
+ } catch (e) {
7062
+ console.error(`migrate failed: ${e.message}`);
7063
+ process.exit(1);
7064
+ }
7065
+ break;
7066
+ }
7067
+ case 'hermes': {
7068
+ // Phase G: import a Hermes Agent install (spec §10).
7069
+ if (rest.positional[0] !== 'import') {
7070
+ console.error('Usage: lazyclaw hermes import [--from <dir>]');
7071
+ process.exit(2);
7072
+ }
7073
+ const from = rest.flags.from;
7074
+ const mod = await import('./scripts/hermes-import.mjs');
7075
+ try {
7076
+ const { src, dst, counts } = mod.importHermes({ from });
7077
+ console.log(`hermes import: ${src} → ${dst}`);
7078
+ console.log(` skills: ${counts.skills} skins: ${counts.skins}`);
7079
+ process.exit(0);
7080
+ } catch (e) { console.error(`hermes import failed: ${e.message}`); process.exit(1); }
7081
+ break;
7082
+ }
7083
+ case 'openclaw': {
7084
+ // Phase G: import an OpenClaw install (spec §10).
7085
+ if (rest.positional[0] !== 'import') {
7086
+ console.error('Usage: lazyclaw openclaw import [--from <dir>]');
7087
+ process.exit(2);
7088
+ }
7089
+ const from = rest.flags.from;
7090
+ const mod = await import('./scripts/openclaw-import.mjs');
7091
+ try {
7092
+ const { src, dst, counts } = mod.importOpenclaw({ from });
7093
+ console.log(`openclaw import: ${src} → ${dst} skills:${counts.skills}`);
7094
+ process.exit(0);
7095
+ } catch (e) { console.error(`openclaw import failed: ${e.message}`); process.exit(1); }
7096
+ break;
7097
+ }
7098
+ case 'trajectories': {
7099
+ // Phase H1: read-only trajectory exporter (spec §2.7).
7100
+ // Usage: lazyclaw trajectories export --format <atropos|axolotl|openai-ft|jsonl>
7101
+ // [--since 7d] [--filter "outcome=done"] [--out ./dir]
7102
+ if (rest.positional[0] !== 'export') {
7103
+ console.error('Usage: lazyclaw trajectories export --format <atropos|axolotl|openai-ft|jsonl> [--since 7d] [--filter "outcome=done"] [--out <dir>]');
7104
+ process.exit(2);
7105
+ }
7106
+ const mod = await import('./mas/trajectory_export.mjs');
7107
+ const format = rest.flags.format || 'jsonl';
7108
+ if (!mod.FORMATS.includes(format)) {
7109
+ console.error(`trajectories export: unknown format "${format}" — choose ${mod.FORMATS.join('|')}`);
7110
+ process.exit(2);
7111
+ }
7112
+ try {
7113
+ const r = await mod.exportTrajectories({
7114
+ format,
7115
+ since: rest.flags.since,
7116
+ filter: mod.parseFilterArg(rest.flags.filter),
7117
+ outDir: rest.flags.out,
7118
+ });
7119
+ console.log(`exported ${r.count} trajectories (${r.format}) → ${r.outFile}`);
7120
+ process.exit(0);
7121
+ } catch (e) {
7122
+ console.error(`trajectories export failed: ${e.message}`);
7123
+ process.exit(1);
7124
+ }
7125
+ break;
7126
+ }
6463
7127
  case 'chat': {
6464
7128
  await cmdChat(rest.flags);
6465
7129
  break;
@@ -6489,6 +7153,10 @@ async function main() {
6489
7153
  await cmdRates(sub, rest.positional.slice(1), rest.flags);
6490
7154
  break;
6491
7155
  }
7156
+ case 'sandbox': {
7157
+ process.exit(await cmdSandbox(rest.positional, rest.flags));
7158
+ break;
7159
+ }
6492
7160
  case 'auth': {
6493
7161
  const sub = rest.positional[0];
6494
7162
  await cmdAuth(sub, rest.positional.slice(1), rest.flags);
@@ -6548,6 +7216,16 @@ async function main() {
6548
7216
  await cmdSlack(sub, rest.positional.slice(1), rest.flags);
6549
7217
  break;
6550
7218
  }
7219
+ case 'telegram': {
7220
+ const sub = rest.positional[0];
7221
+ await cmdTelegram(sub, rest.positional.slice(1), rest.flags);
7222
+ break;
7223
+ }
7224
+ case 'matrix': {
7225
+ const sub = rest.positional[0];
7226
+ await cmdMatrix(sub, rest.positional.slice(1), rest.flags);
7227
+ break;
7228
+ }
6551
7229
  case 'team': {
6552
7230
  const sub = rest.positional[0];
6553
7231
  await cmdTeam(sub, rest.positional.slice(1), rest.flags);
@@ -6566,6 +7244,31 @@ async function main() {
6566
7244
  await cmdDashboard(rest.flags);
6567
7245
  break;
6568
7246
  }
7247
+ case 'channels': {
7248
+ const sub = (rest.positional[0] || 'list').toLowerCase();
7249
+ const { createLoader, listInstalled } = await import('./channels/loader.mjs');
7250
+ const cfgDir = path.dirname(configPath());
7251
+ const loader = createLoader({ configDir: cfgDir });
7252
+ if (sub === 'install') {
7253
+ const name = rest.positional[1];
7254
+ if (!name) { process.stderr.write('usage: lazyclaw channels install <@lazyclaw/channel-name>\n'); process.exit(2); }
7255
+ const info = await loader.install(name);
7256
+ process.stdout.write(`installed ${info.name}@${info.version}\n`);
7257
+ break;
7258
+ }
7259
+ if (sub === 'remove' || sub === 'uninstall') {
7260
+ const name = rest.positional[1];
7261
+ if (!name) { process.stderr.write('usage: lazyclaw channels remove <@lazyclaw/channel-name>\n'); process.exit(2); }
7262
+ await loader.remove(name);
7263
+ process.stdout.write(`removed ${name}\n`);
7264
+ break;
7265
+ }
7266
+ // list
7267
+ const rows = listInstalled(cfgDir);
7268
+ if (!rows.length) { process.stdout.write('no channel plugins installed\n'); break; }
7269
+ for (const r of rows) process.stdout.write(`${r.name}\t${r.version}\n`);
7270
+ break;
7271
+ }
6569
7272
  case 'daemon': {
6570
7273
  await cmdDaemon(rest.flags);
6571
7274
  break;