@miruzero/zero-agent 0.2.0 → 0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miruzero/zero-agent",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "MiruZero local worker agent for multi-device tool execution.",
5
5
  "type": "commonjs",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -4,10 +4,15 @@ const os = require('node:os');
4
4
  const config = require('./config');
5
5
  const caps = require('./capabilities');
6
6
  const { requestJson } = require('./http');
7
+ const localTools = require('./local-tools');
7
8
 
8
9
  const nowIso = () => new Date().toISOString();
9
10
 
10
11
  const serverBase = (server) => String(server || 'https://zero.miru.work').replace(/\/$/, '');
12
+ const trace = (env, event, payload = {}) => {
13
+ if (env.ZERO_TRACE !== '1') return;
14
+ console.log(JSON.stringify({ at: nowIso(), event, ...payload }));
15
+ };
11
16
 
12
17
  const initDevice = ({ name, server }, env = process.env) => {
13
18
  const host = os.hostname();
@@ -95,6 +100,34 @@ const syncCapabilitiesOnce = async (env = process.env) => {
95
100
  return { ok: result.ok, status: result.status, body: result.json };
96
101
  };
97
102
 
103
+
104
+ const pollTaskOnce = async (env = process.env) => {
105
+ const device = getPairedDevice(env);
106
+ if (!device) return { ok: false, code: 'NOT_PAIRED' };
107
+ const nextUrl = serverBase(device.server) + '/devices/' + encodeURIComponent(device.device_id) + '/tasks/next';
108
+ const next = await requestJson(nextUrl, { method: 'GET', token: device.device_token });
109
+ if (!next.ok) return { ok: false, status: next.status, body: next.json };
110
+ const task = next.json?.task || null;
111
+ if (!task) return { ok: true, task: null };
112
+ const started = Date.now();
113
+ trace(env, 'agent.task.received', { task_id: task.task_id, tool: task.tool, args: task.arguments || {} });
114
+ const resultUrl = serverBase(device.server) + '/devices/' + encodeURIComponent(device.device_id)
115
+ + '/tasks/' + encodeURIComponent(task.task_id) + '/result';
116
+ try {
117
+ const result = localTools.execute(task.tool, task.arguments || {}, env);
118
+ trace(env, 'agent.task.done', { task_id: task.task_id, ok: true, duration_ms: Date.now() - started });
119
+ const posted = await requestJson(resultUrl, { method: 'POST', token: device.device_token, body: { ok: true, result } });
120
+ const finalTask = posted.json?.task || task;
121
+ return { ok: posted.ok, task: finalTask, result, posted: { status: posted.status, body: posted.json } };
122
+ } catch (error) {
123
+ const payload = { ok: false, error: { code: error.code || 'AGENT_EXEC_ERROR', message: error.message || String(error) } };
124
+ trace(env, 'agent.task.done', { task_id: task.task_id, ok: false, error: payload.error, duration_ms: Date.now() - started });
125
+ const posted = await requestJson(resultUrl, { method: 'POST', token: device.device_token, body: payload });
126
+ const finalTask = posted.json?.task || task;
127
+ return { ok: false, task: finalTask, error: payload.error, posted: { status: posted.status, body: posted.json } };
128
+ }
129
+ };
130
+
98
131
  const listDevices = async (env = process.env) => {
99
132
  const device = config.loadDevice(env);
100
133
  const result = await requestJson(`${serverBase(device?.server)}/devices`);
@@ -106,6 +139,7 @@ module.exports = {
106
139
  heartbeatPayload,
107
140
  heartbeatOnce,
108
141
  syncCapabilitiesOnce,
142
+ pollTaskOnce,
109
143
  listDevices,
110
144
  getPairedDevice
111
145
  };
package/src/cli.js CHANGED
@@ -93,7 +93,8 @@ const runStartOnce = async (env) => {
93
93
  const heartbeat = await agent.heartbeatOnce(env);
94
94
  if (!heartbeat.ok && heartbeat.code === 'NOT_PAIRED') return heartbeat;
95
95
  const capability = await agent.syncCapabilitiesOnce(env);
96
- return { heartbeat, capability };
96
+ const task = await agent.pollTaskOnce(env);
97
+ return { heartbeat, capability, task };
97
98
  };
98
99
 
99
100
  const printStartBanner = (env) => {
@@ -113,6 +114,7 @@ const printLoopResult = (result) => {
113
114
  const hb = result.heartbeat || {};
114
115
  const cap = result.capability || {};
115
116
  const device = cap.body?.device || hb.body?.device || null;
117
+ const taskState = result.task?.task ? (result.task.task.task_id + ':' + (result.task.task.status || 'done')) : 'none';
116
118
  console.log(JSON.stringify({
117
119
  at: new Date().toISOString(),
118
120
  heartbeat: okMark(Boolean(hb.ok)),
@@ -121,7 +123,8 @@ const printLoopResult = (result) => {
121
123
  name: device?.name || null,
122
124
  host: device?.host || null,
123
125
  providers: device?.provider_count || null,
124
- last_seen: device?.last_seen || null
126
+ last_seen: device?.last_seen || null,
127
+ task: taskState
125
128
  }));
126
129
  };
127
130
 
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
7
+ const textDecoder = new TextDecoder('utf-8', { fatal: false });
8
+ const err = (code, message) => Object.assign(new Error(message), { code });
9
+ const normalize = (value) => path.resolve(String(value)).toLowerCase();
10
+
11
+ const rootsFor = (env = process.env) => {
12
+ const configured = String(env.ZERO_AGENT_ROOTS || '')
13
+ .split(';')
14
+ .map((item) => item.trim())
15
+ .filter(Boolean);
16
+ const roots = configured.length ? configured : [os.homedir(), process.cwd()];
17
+ return roots.map((item) => path.resolve(item));
18
+ };
19
+
20
+ const resolveAllowed = (inputPath, env = process.env) => {
21
+ if (!inputPath || typeof inputPath !== 'string') throw err('INVALID_PATH', 'path is required');
22
+ const roots = rootsFor(env);
23
+ const target = path.resolve(inputPath);
24
+ const targetNorm = normalize(target);
25
+ const ok = roots.some((root) => {
26
+ const rootNorm = normalize(root);
27
+ return targetNorm === rootNorm || targetNorm.startsWith(rootNorm + path.sep.toLowerCase());
28
+ });
29
+ if (!ok) throw err('PATH_NOT_ALLOWED', 'path is outside agent allowed roots');
30
+ return target;
31
+ };
32
+
33
+ const readUtf8 = (file) => {
34
+ const data = fs.readFileSync(file);
35
+ if (data.includes(0)) throw err('BINARY_FILE', 'binary file is not supported');
36
+ return textDecoder.decode(data);
37
+ };
38
+
39
+ const entry = (fullPath, root) => {
40
+ const stat = fs.statSync(fullPath);
41
+ return {
42
+ path: fullPath,
43
+ relativePath: path.relative(root, fullPath).replace(/\\/g, '/'),
44
+ type: stat.isDirectory() ? 'directory' : 'file',
45
+ size: stat.size
46
+ };
47
+ };
48
+
49
+ const walk = (root, options = {}) => {
50
+ const depth = options.depth ?? Infinity;
51
+ const filesOnly = options.filesOnly === true;
52
+ const out = [];
53
+ const visit = (dir, level) => {
54
+ if (level > depth) return;
55
+ for (const name of fs.readdirSync(dir)) {
56
+ const full = path.join(dir, name);
57
+ const item = entry(full, root);
58
+ if (!filesOnly || item.type === 'file') out.push(item);
59
+ if (item.type === 'directory') visit(full, level + 1);
60
+ }
61
+ };
62
+ visit(root, 1);
63
+ return out;
64
+ };
65
+
66
+ const wildcard = (pattern) => new RegExp('^' + String(pattern)
67
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
68
+ .replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
69
+
70
+ const numbered = (lines, startIndex) => lines
71
+ .map((line, index) => String(startIndex + index + 1).padStart(4, ' ') + '\t' + line)
72
+ .join('\n');
73
+
74
+ const listDirectory = (args, env) => {
75
+ const root = resolveAllowed(args.path, env);
76
+ const depth = Math.max(1, Math.min(args.depth || 2, 5));
77
+ return { ok: true, path: root, depth, entries: walk(root, { depth }) };
78
+ };
79
+
80
+ const getFileInfo = (args, env) => {
81
+ const target = resolveAllowed(args.path, env);
82
+ const stat = fs.statSync(target);
83
+ return {
84
+ ok: true,
85
+ path: target,
86
+ type: stat.isDirectory() ? 'directory' : 'file',
87
+ size: stat.size,
88
+ createdAt: stat.birthtime.toISOString(),
89
+ modifiedAt: stat.mtime.toISOString(),
90
+ lineCount: stat.isFile() ? readUtf8(target).split(/\r?\n/).length : null
91
+ };
92
+ };
93
+
94
+ const readFile = (args, env) => {
95
+ const file = resolveAllowed(args.path, env);
96
+ const lines = readUtf8(file).split(/\r?\n/);
97
+ const offset = Number.isInteger(args.offset) ? args.offset : 0;
98
+ const length = Number.isInteger(args.length) ? args.length : 200;
99
+ const start = offset < 0 ? Math.max(lines.length + offset, 0) : Math.max(offset, 0);
100
+ const slice = lines.slice(start, start + length);
101
+ return { ok: true, path: file, totalLines: lines.length, offset: start, length: slice.length, content: numbered(slice, start) };
102
+ };
103
+
104
+ const globFiles = (args, env) => {
105
+ const root = resolveAllowed(args.path, env);
106
+ const rx = wildcard(args.pattern || '*');
107
+ const max = Math.max(1, Math.min(args.maxResults || 200, 1000));
108
+ const files = walk(root, { filesOnly: true })
109
+ .filter((item) => rx.test(path.basename(item.path)) || rx.test(item.relativePath))
110
+ .slice(0, max);
111
+ return { ok: true, path: root, pattern: args.pattern, files };
112
+ };
113
+
114
+ const grepFiles = (args, env) => {
115
+ const root = resolveAllowed(args.path, env);
116
+ const max = Math.max(1, Math.min(args.maxResults || 200, 1000));
117
+ const rx = args.literal ? null : new RegExp(String(args.pattern), 'i');
118
+ const fileRx = args.filePattern ? wildcard(args.filePattern) : null;
119
+ const matches = [];
120
+ for (const file of walk(root, { filesOnly: true })) {
121
+ if (fileRx && !fileRx.test(path.basename(file.path)) && !fileRx.test(file.relativePath)) continue;
122
+ let text;
123
+ try { text = readUtf8(file.path); } catch { continue; }
124
+ const lines = text.split(/\r?\n/);
125
+ for (let index = 0; index < lines.length; index += 1) {
126
+ const line = lines[index];
127
+ const hit = args.literal ? line.includes(String(args.pattern)) : rx.test(line);
128
+ if (!hit) continue;
129
+ matches.push({ path: file.path, relativePath: file.relativePath, line: index + 1, text: line });
130
+ if (matches.length >= max) return { ok: true, path: root, pattern: args.pattern, matches };
131
+ }
132
+ }
133
+ return { ok: true, path: root, pattern: args.pattern, matches };
134
+ };
135
+
136
+ const handlers = { listDirectory, getFileInfo, readFile, globFiles, grepFiles };
137
+
138
+ exports.execute = (tool, args = {}, env = process.env) => {
139
+ const cleanTool = String(tool || '').replace(/^command\./, '');
140
+ const handler = handlers[cleanTool];
141
+ if (!handler) throw err('UNKNOWN_AGENT_TOOL', 'unknown or unsafe agent tool: ' + tool);
142
+ return handler(args, env);
143
+ };
144
+
145
+ exports._private = { rootsFor, resolveAllowed, wildcard };