zigmini 0.9.0-beta.2 → 1.0.0-beta.1
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/README.ko.md +35 -5
- package/README.md +35 -5
- package/assets/agent/gateway-rules.md +1 -1
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +51 -11
- package/docs/manual.md +52 -12
- package/host/omo-bridge/agent-control.ts +274 -0
- package/host/omo-bridge/bridge.ts +1 -1
- package/host/omo-bridge/continuity-control.ts +1 -1
- package/host/omo-bridge/model-control.ts +15 -3
- package/host/omo-bridge/task-control.ts +26 -16
- package/host/omo-check/patches/omo-5.1.48-compaction-recovery.patch +912 -0
- package/host/omo-check/patches/omo-5.1.48-gateway-port.manifest.json +12 -11
- package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
- package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
- package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
- package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
- package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
- package/package.json +3 -2
- package/src/agent/api.ts +215 -0
- package/src/agent/definition.ts +177 -0
- package/src/agent/fields.ts +42 -0
- package/src/agent/files.ts +192 -0
- package/src/agent/legacy-files.ts +46 -0
- package/src/agent/list.ts +21 -0
- package/src/agent-rules.ts +14 -5
- package/src/approvals.ts +6 -1
- package/src/cli/main.ts +33 -0
- package/src/cli/messages.ts +10 -2
- package/src/cli/rotate-main.ts +2 -1
- package/src/cli/scan.ts +414 -0
- package/src/cli/security-import.ts +155 -0
- package/src/compact.ts +22 -0
- package/src/decisions.ts +6 -4
- package/src/engine/compat.json +3 -3
- package/src/engine/index.ts +1 -0
- package/src/engine/schema-guard.ts +12 -4
- package/src/engine/sdk.ts +8 -0
- package/src/formal-verification.ts +366 -0
- package/src/gc.ts +37 -6
- package/src/hub.ts +525 -85
- package/src/main.ts +95 -9
- package/src/maintenance.ts +1 -1
- package/src/manual.ts +9 -2
- package/src/omo/client.ts +2 -0
- package/src/profile-prompt.ts +73 -0
- package/src/profile.ts +33 -10
- package/src/protocol.ts +11 -2
- package/src/question-notifications.ts +4 -1
- package/src/reply-notifications.ts +7 -1
- package/src/repo-security-setting.ts +33 -0
- package/src/runtime/paths.ts +16 -0
- package/src/security/baseline.ts +170 -0
- package/src/security/dex-strings.ts +242 -0
- package/src/security/repo-scan.ts +369 -0
- package/src/security/revision-probe.ts +144 -0
- package/src/security/terms.ts +97 -0
- package/src/security-allow.ts +1 -0
- package/src/security-baseline.ts +113 -0
- package/src/security-check.ts +91 -16
- package/src/server-text.ts +38 -0
- package/src/store.ts +142 -16
- package/src/transcript.ts +7 -1
- package/src/ui-language.ts +33 -0
- package/src/update/check.ts +7 -3
- package/src/update/handoff-system.ts +24 -16
- package/src/update/handoff.ts +23 -12
- package/src/update/helper.ts +72 -36
- package/src/update/request.ts +46 -30
- package/src/update/state.ts +21 -11
- package/src/update/strategy.ts +12 -5
- package/src/update/target.ts +32 -15
- package/src/usage.ts +43 -20
- package/src/version.ts +39 -16
- package/src/voice/agents.ts +8 -3
- package/src/voice/install.ts +29 -18
- package/src/work-watch.ts +40 -18
- package/web/agent-icon.js +15 -9
- package/web/agents.d.ts +201 -0
- package/web/agents.js +813 -0
- package/web/app-download.js +32 -19
- package/web/app.js +1407 -278
- package/web/autocopy.js +6 -1
- package/web/boot.js +27 -8
- package/web/commands.js +20 -11
- package/web/decisions.js +70 -49
- package/web/devlog.js +25 -19
- package/web/diagram.js +8 -3
- package/web/enroll.js +43 -22
- package/web/gc.js +32 -19
- package/web/i18n/agent-icon.js +24 -0
- package/web/i18n/agents.js +48 -0
- package/web/i18n/app-download.js +26 -0
- package/web/i18n/autocopy.js +5 -0
- package/web/i18n/boot.js +24 -0
- package/web/i18n/commands.js +34 -0
- package/web/i18n/decisions.js +24 -0
- package/web/i18n/devlog.js +60 -0
- package/web/i18n/diagram.js +12 -0
- package/web/i18n/enroll.js +22 -0
- package/web/i18n/gc.js +38 -0
- package/web/i18n/lastsent.js +10 -0
- package/web/i18n/main.d.ts +4 -0
- package/web/i18n/main.js +527 -0
- package/web/i18n/manual.d.ts +4 -0
- package/web/i18n/manual.js +14 -0
- package/web/i18n/memo.js +30 -0
- package/web/i18n/notifications.js +16 -0
- package/web/i18n/profile.js +13 -0
- package/web/i18n/reference.js +26 -0
- package/web/i18n/security-inbox.js +40 -0
- package/web/i18n/slash.js +38 -0
- package/web/i18n/turn-stats.js +5 -0
- package/web/i18n/update.js +42 -0
- package/web/i18n/voice.js +28 -0
- package/web/i18n/wake.js +16 -0
- package/web/i18n/workid.js +10 -0
- package/web/i18n/workstatus.js +20 -0
- package/web/i18n.d.ts +20 -0
- package/web/i18n.js +63 -0
- package/web/index.html +156 -96
- package/web/lastsent.js +9 -4
- package/web/manual.d.ts +1 -1
- package/web/manual.js +11 -5
- package/web/memo.js +18 -13
- package/web/notifications.js +12 -7
- package/web/profile.js +42 -17
- package/web/reference.js +20 -15
- package/web/security-inbox.d.ts +18 -1
- package/web/security-inbox.js +173 -4
- package/web/slash.js +23 -13
- package/web/style.css +132 -3
- package/web/tooltip.js +5 -2
- package/web/turn-stats.js +6 -2
- package/web/update.js +31 -23
- package/web/voice.js +18 -8
- package/web/wake.d.ts +4 -0
- package/web/wake.js +7 -3
- package/web/workid.js +12 -2
- package/web/workstatus.js +36 -32
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { runtimePaths, type Env } from "../runtime/paths.ts";
|
|
5
|
+
import { say, type Text } from "../server-text.ts";
|
|
6
|
+
import { parseAgentDefinition, AgentDefinitionError, serializeAgentDefinition, type AgentDefinition } from "./definition.ts";
|
|
7
|
+
import { migrateLegacyAgentFiles } from "./legacy-files.ts";
|
|
8
|
+
|
|
9
|
+
export function agentTrashEntry(id: string, timestamp: string): string {
|
|
10
|
+
return `${id}-${timestamp}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Trash names retain the UTC ISO timestamp used by the file store, including colons. */
|
|
14
|
+
export function agentTrashTimestamp(entry: string): number | null {
|
|
15
|
+
const timestamp = /-(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)$/.exec(entry)?.[1];
|
|
16
|
+
return timestamp === undefined ? null : Date.parse(timestamp);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A broken entry must not prevent the other agents from being listed. */
|
|
20
|
+
export function unreadableAgent(error: unknown): boolean {
|
|
21
|
+
return error instanceof AgentDefinitionError || error instanceof AgentFileError
|
|
22
|
+
|| (error instanceof Error && "code" in error
|
|
23
|
+
&& ["ENOENT", "EACCES", "EPERM", "ENOTDIR", "EISDIR", "ELOOP"].includes(String(error.code)));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const COPY_SUFFIX: Text = { en: " copy", ko: " 사본" };
|
|
27
|
+
|
|
28
|
+
export class AgentFileError extends Error {
|
|
29
|
+
constructor(readonly code: "path" | "exists" | "id", message: string) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "AgentFileError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Synchronous file operations keep each gateway mutation uninterrupted by another request. */
|
|
36
|
+
export class AgentFileStore {
|
|
37
|
+
readonly root: string;
|
|
38
|
+
private lastTime = 0;
|
|
39
|
+
|
|
40
|
+
constructor(env: Env = process.env) {
|
|
41
|
+
const root = resolve(runtimePaths(env).agents);
|
|
42
|
+
mkdirSync(root, { recursive: true });
|
|
43
|
+
if (lstatSync(root).isSymbolicLink()) throw new AgentFileError("path", "Agent root cannot be a symlink");
|
|
44
|
+
this.root = realpathSync(root);
|
|
45
|
+
migrateLegacyAgentFiles(this.root);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Check existing ancestors too, so a missing child cannot bypass a symlink check. */
|
|
49
|
+
private checked(path: string): string {
|
|
50
|
+
const target = resolve(this.root, path);
|
|
51
|
+
const inside = (candidate: string): boolean => {
|
|
52
|
+
const rel = relative(this.root, candidate);
|
|
53
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
54
|
+
};
|
|
55
|
+
if (!inside(target)) throw new AgentFileError("path", "Path escapes agent root");
|
|
56
|
+
let existing = target;
|
|
57
|
+
while (!existsSync(existing)) {
|
|
58
|
+
// Dangling symlinks must not be mistaken for new files.
|
|
59
|
+
try {
|
|
60
|
+
if (lstatSync(existing).isSymbolicLink()) throw new AgentFileError("path", "Dangling agent symlink");
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
63
|
+
}
|
|
64
|
+
existing = dirname(existing);
|
|
65
|
+
}
|
|
66
|
+
if (!inside(realpathSync(existing))) throw new AgentFileError("path", "Symlink escapes agent root");
|
|
67
|
+
return target;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private folder(id: string): string {
|
|
71
|
+
if (!/^[a-f0-9]{8}$/.test(id)) throw new AgentFileError("id", "Invalid agent id");
|
|
72
|
+
return this.checked(id);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private timestamp(): string {
|
|
76
|
+
this.lastTime = Math.max(Date.now(), this.lastTime + 1);
|
|
77
|
+
return new Date(this.lastTime).toISOString();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
create(source: string): string {
|
|
81
|
+
parseAgentDefinition(source);
|
|
82
|
+
const id = randomUUID().slice(0, 8);
|
|
83
|
+
const folder = this.folder(id);
|
|
84
|
+
mkdirSync(folder);
|
|
85
|
+
mkdirSync(join(folder, "knowledge/source"), { recursive: true });
|
|
86
|
+
mkdirSync(join(folder, "knowledge/notes/daily"), { recursive: true });
|
|
87
|
+
writeFileSync(join(folder, "knowledge/MEMORY.md"), "", { flag: "wx" });
|
|
88
|
+
writeFileSync(join(folder, "agent.md"), source, { flag: "wx" });
|
|
89
|
+
return id;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
read(id: string): AgentDefinition {
|
|
93
|
+
return parseAgentDefinition(readFileSync(this.checked(join(this.folder(id), "agent.md")), "utf8"));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
list(): readonly { readonly id: string; readonly definition: AgentDefinition }[] {
|
|
97
|
+
return readdirSync(this.root, { withFileTypes: true })
|
|
98
|
+
.filter((entry) => !entry.name.startsWith(".") && (entry.isDirectory() || entry.isSymbolicLink()))
|
|
99
|
+
.flatMap((entry) => {
|
|
100
|
+
try {
|
|
101
|
+
return [{ id: entry.name, definition: this.read(entry.name) }];
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (unreadableAgent(error)) return [];
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private saveFile(path: string, source: string): void {
|
|
110
|
+
const file = this.checked(path);
|
|
111
|
+
const history = this.checked(join(dirname(file), file === join(this.root, "defaults.md") ? ".history" : "history"));
|
|
112
|
+
if (existsSync(file)) {
|
|
113
|
+
mkdirSync(history, { recursive: true });
|
|
114
|
+
let previous = this.checked(join(history, `${this.timestamp()}.md`));
|
|
115
|
+
while (existsSync(previous)) previous = this.checked(join(history, `${this.timestamp()}.md`));
|
|
116
|
+
copyFileSync(file, previous);
|
|
117
|
+
}
|
|
118
|
+
writeFileSync(file, source);
|
|
119
|
+
if (existsSync(history)) {
|
|
120
|
+
const entries = readdirSync(history).filter((name) => /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z\.md$/.test(name)).sort().reverse();
|
|
121
|
+
for (const name of entries.slice(20)) unlinkSync(this.checked(join(history, name)));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
save(id: string, source: string): void {
|
|
126
|
+
parseAgentDefinition(source);
|
|
127
|
+
this.saveFile(join(this.folder(id), "agent.md"), source);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
readDefaults(): AgentDefinition | undefined {
|
|
131
|
+
const file = this.checked("defaults.md");
|
|
132
|
+
return existsSync(file) ? parseAgentDefinition(readFileSync(file, "utf8"), "defaults") : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
saveDefaults(source: string): void {
|
|
136
|
+
parseAgentDefinition(source, "defaults");
|
|
137
|
+
this.saveFile(join(this.root, "defaults.md"), source);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Clone only regular source files and directories; never carry symlinks into the new agent. */
|
|
141
|
+
private copy(source: string, target: string): void {
|
|
142
|
+
const file = this.checked(source);
|
|
143
|
+
const destination = this.checked(target);
|
|
144
|
+
const stat = lstatSync(file);
|
|
145
|
+
if (stat.isSymbolicLink()) throw new AgentFileError("path", "Cannot clone a symlink");
|
|
146
|
+
if (stat.isDirectory()) {
|
|
147
|
+
mkdirSync(destination, { recursive: true });
|
|
148
|
+
for (const name of readdirSync(file)) this.copy(join(file, name), join(destination, name));
|
|
149
|
+
} else {
|
|
150
|
+
copyFileSync(file, destination);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
clone(id: string): string {
|
|
155
|
+
const definition = this.read(id);
|
|
156
|
+
const original = this.folder(id);
|
|
157
|
+
// Validate the copy set before allocating the new agent.
|
|
158
|
+
const validate = (path: string): void => {
|
|
159
|
+
const stat = lstatSync(this.checked(path));
|
|
160
|
+
if (stat.isSymbolicLink()) throw new AgentFileError("path", "Cannot clone a symlink");
|
|
161
|
+
if (stat.isDirectory()) for (const name of readdirSync(path)) validate(join(path, name));
|
|
162
|
+
};
|
|
163
|
+
validate(join(original, "knowledge/source"));
|
|
164
|
+
validate(join(original, "knowledge/MEMORY.md"));
|
|
165
|
+
const cloned = this.create(serializeAgentDefinition({
|
|
166
|
+
...definition, fields: { ...definition.fields, name: `${definition.fields.name}${say(COPY_SUFFIX)}` },
|
|
167
|
+
}));
|
|
168
|
+
this.copy(join(original, "knowledge/source"), join(this.folder(cloned), "knowledge/source"));
|
|
169
|
+
this.copy(join(original, "knowledge/MEMORY.md"), join(this.folder(cloned), "knowledge/MEMORY.md"));
|
|
170
|
+
return cloned;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
trash(id: string): string {
|
|
174
|
+
const source = this.folder(id);
|
|
175
|
+
const trash = this.checked(".trash");
|
|
176
|
+
mkdirSync(trash, { recursive: true });
|
|
177
|
+
let entry = agentTrashEntry(id, this.timestamp());
|
|
178
|
+
while (existsSync(this.checked(join(trash, entry)))) entry = agentTrashEntry(id, this.timestamp());
|
|
179
|
+
renameSync(source, this.checked(join(trash, entry)));
|
|
180
|
+
return entry;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
restore(entry: string): string {
|
|
184
|
+
const match = /^([a-f0-9]{8})-\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.exec(entry);
|
|
185
|
+
const id = match?.[1];
|
|
186
|
+
if (!id) throw new AgentFileError("id", "Invalid trash entry");
|
|
187
|
+
const target = this.folder(id);
|
|
188
|
+
if (existsSync(target)) throw new AgentFileError("exists", "Agent id already exists");
|
|
189
|
+
renameSync(this.checked(join(".trash", entry)), target);
|
|
190
|
+
return id;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { lstatSync, mkdirSync, readdirSync, renameSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Move only unclaimed legacy entries; keep collisions and all file bytes intact. */
|
|
5
|
+
export function migrateLegacyAgentFiles(root: string): void {
|
|
6
|
+
const legacy = join(dirname(root), "personas");
|
|
7
|
+
if (!readdirSync(dirname(root)).includes("personas")) return;
|
|
8
|
+
if (!lstatSync(legacy).isDirectory()) {
|
|
9
|
+
console.warn("agent_legacy_files_skipped", { count: 1 });
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
let collisions = 0;
|
|
13
|
+
const move = (source: string, target: string): void => {
|
|
14
|
+
if (readdirSync(dirname(target)).includes(basename(target))) {
|
|
15
|
+
collisions++;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
renameSync(source, target);
|
|
19
|
+
if (lstatSync(target).isDirectory()) {
|
|
20
|
+
const names = readdirSync(target);
|
|
21
|
+
if (names.includes("persona.md")) {
|
|
22
|
+
if (names.includes("agent.md")) collisions++;
|
|
23
|
+
else renameSync(join(target, "persona.md"), join(target, "agent.md"));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
for (const entry of readdirSync(legacy, { withFileTypes: true })) {
|
|
28
|
+
const source = join(legacy, entry.name);
|
|
29
|
+
const target = join(root, entry.name);
|
|
30
|
+
if ((entry.name === ".history" || entry.name === ".trash") && entry.isDirectory()) {
|
|
31
|
+
if (readdirSync(root).includes(entry.name) && !lstatSync(target).isDirectory()) {
|
|
32
|
+
collisions++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
mkdirSync(target, { recursive: true });
|
|
36
|
+
for (const child of readdirSync(source, { withFileTypes: true })) {
|
|
37
|
+
if (child.isSymbolicLink()) { collisions++; continue; }
|
|
38
|
+
move(join(source, child.name), join(target, child.name));
|
|
39
|
+
}
|
|
40
|
+
} else if ((entry.name === "defaults.md" && entry.isFile())
|
|
41
|
+
|| (/^[a-f0-9]{8}$/.test(entry.name) && entry.isDirectory())) {
|
|
42
|
+
move(source, target);
|
|
43
|
+
} else collisions++;
|
|
44
|
+
}
|
|
45
|
+
if (collisions > 0) console.warn("agent_legacy_files_collisions", { count: collisions });
|
|
46
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AgentDefinition } from "./definition.ts";
|
|
2
|
+
|
|
3
|
+
export interface AgentListEntry { readonly name: string; readonly description: string; readonly link: string }
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The read-only list main sees: name, one-line purpose and the gateway-relative web link of the conversation.
|
|
7
|
+
* `visible` holds only non-hidden conversations, so a trashed agent (hidden conversation) never appears.
|
|
8
|
+
* Definition body and knowledge are never part of the result.
|
|
9
|
+
*/
|
|
10
|
+
export function agentList(
|
|
11
|
+
agents: readonly { readonly id: string; readonly definition: AgentDefinition }[],
|
|
12
|
+
visible: readonly { readonly id: string; readonly agentId?: string | null }[],
|
|
13
|
+
): AgentListEntry[] {
|
|
14
|
+
const entries: AgentListEntry[] = [];
|
|
15
|
+
for (const { id, definition } of agents) {
|
|
16
|
+
const conv = visible.find((row) => row.agentId === id);
|
|
17
|
+
if (!conv) continue;
|
|
18
|
+
entries.push({ name: String(definition.fields.name), description: String(definition.fields.description), link: `/#conv=${encodeURIComponent(conv.id)}` });
|
|
19
|
+
}
|
|
20
|
+
return entries;
|
|
21
|
+
}
|
package/src/agent-rules.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { type Env, runtimeHome } from "./runtime/paths.ts";
|
|
4
|
+
import { type Env, runtimeHome, runtimePaths } from "./runtime/paths.ts";
|
|
5
|
+
import { readVoiceSetting } from "./voice-setting.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Improvement 117: the agent rules the bridge appends to the main and work session instructions. The package file
|
|
@@ -24,7 +25,15 @@ export const INSTALL_RULES_TEMPLATE = `<!--
|
|
|
24
25
|
|
|
25
26
|
export type RulesStatus = "used" | "missing" | "empty" | "too_large" | "unreadable";
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
/** Improvement 250: the package rule text between these markers is the repository security check duty. */
|
|
29
|
+
export const REPO_SECURITY_SECTION = /<!--repo-security-check-->[\s\S]*?<!--\/repo-security-check-->/g;
|
|
30
|
+
|
|
31
|
+
/** What to drop from the package rules: the repository security check duty while that switch is off. */
|
|
32
|
+
export function packageRulesDrop(env: Env = process.env): RegExp | undefined {
|
|
33
|
+
return readVoiceSetting(runtimePaths(env).repoSecurityCheckFile) ? undefined : REPO_SECURITY_SECTION;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readRulesFile(path: string, drop?: RegExp): { status: RulesStatus; text: string } {
|
|
28
37
|
if (!existsSync(path)) return { status: "missing", text: "" };
|
|
29
38
|
let raw: string;
|
|
30
39
|
try {
|
|
@@ -33,20 +42,20 @@ export function readRulesFile(path: string): { status: RulesStatus; text: string
|
|
|
33
42
|
} catch {
|
|
34
43
|
return { status: "unreadable", text: "" };
|
|
35
44
|
}
|
|
36
|
-
const text = raw.replace(/<!--[\s\S]*?-->/g, "").trim();
|
|
45
|
+
const text = (drop ? raw.replace(drop, "") : raw).replace(/<!--[\s\S]*?-->/g, "").trim();
|
|
37
46
|
return text === "" ? { status: "empty", text } : { status: "used", text };
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
/** The text to append after the role instructions, and one log line that names the files but never their content. */
|
|
41
50
|
export function agentRules(env: Env = process.env, packageFile: string = PACKAGE_RULES_FILE): { text: string; log: string } {
|
|
42
51
|
const sources = [
|
|
43
|
-
{ name: "package", path: packageFile, heading: PACKAGE_RULES_HEADING },
|
|
52
|
+
{ name: "package", path: packageFile, heading: PACKAGE_RULES_HEADING, drop: packageRulesDrop(env) },
|
|
44
53
|
{ name: "install", path: join(runtimeHome(env), INSTALL_RULES_FILE), heading: INSTALL_RULES_HEADING },
|
|
45
54
|
];
|
|
46
55
|
const parts: string[] = [];
|
|
47
56
|
const notes: string[] = [];
|
|
48
57
|
for (const source of sources) {
|
|
49
|
-
const read = readRulesFile(source.path);
|
|
58
|
+
const read = readRulesFile(source.path, "drop" in source ? source.drop : undefined);
|
|
50
59
|
if (read.status === "used") parts.push(`${source.heading}\n\n${read.text}`);
|
|
51
60
|
notes.push(`${source.name}=${read.status === "used" ? `used(${source.path})` : read.status}`);
|
|
52
61
|
}
|
package/src/approvals.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import { say, type Text } from "./server-text.ts";
|
|
2
|
+
|
|
3
|
+
/** Improvement 253: the cancel reason the web and app show on a dialog whose deadline passed. */
|
|
4
|
+
export const DIALOG_TIMEOUT_REASON: Text = { en: "Cancelled automatically because the deadline passed.", ko: "기한이 지나 자동으로 취소했습니다." };
|
|
5
|
+
|
|
1
6
|
export type DialogMethod = "select" | "confirm" | "input" | "editor";
|
|
2
7
|
|
|
3
8
|
export interface DialogRequest {
|
|
@@ -41,7 +46,7 @@ export class DialogGate {
|
|
|
41
46
|
const createdAt = Date.now();
|
|
42
47
|
const full: DialogRequest = { ...request, createdAt, deadlineAt: createdAt + this.timeoutMs };
|
|
43
48
|
const timer = setTimeout(() => {
|
|
44
|
-
this.settle({ actionId: full.actionId, answer: { kind: "cancel", reason:
|
|
49
|
+
this.settle({ actionId: full.actionId, answer: { kind: "cancel", reason: say(DIALOG_TIMEOUT_REASON) }, by: "timeout" });
|
|
45
50
|
}, this.timeoutMs);
|
|
46
51
|
this.pending.set(full.actionId, { request: full, resolve: onResolved, timer });
|
|
47
52
|
return full;
|
package/src/cli/main.ts
CHANGED
|
@@ -13,6 +13,11 @@ import { handoffCommand } from "../update/handoff-system.ts";
|
|
|
13
13
|
import { HANDOFF_COMMAND } from "../update/strategy.ts";
|
|
14
14
|
import { ROTATE_COMMAND, ROTATE_RUN_COMMAND, rotateCommand } from "./rotate-main.ts";
|
|
15
15
|
import { EXIT_USAGE, parseMigrateArgs, parseMigrateRunArgs, runMigrate, runMigrateRun, systemMigrateDeps, systemMigrateRunDeps } from "./migrate.ts";
|
|
16
|
+
import { parseImportArgs, runImport, systemImportDeps } from "./security-import.ts";
|
|
17
|
+
import { parseScanArgs, prepareScan, reportScan, resolveScanBaseline, runScan, scanBaselinePaths, scanRepository, syncHashes,
|
|
18
|
+
type ResolvedScanBaseline, type ScanResult } from "./scan.ts";
|
|
19
|
+
import { SCAN_DISABLED_EXIT, scanDisabledNotice } from "../repo-security-setting.ts";
|
|
20
|
+
import { probeRevision } from "../security/revision-probe.ts";
|
|
16
21
|
|
|
17
22
|
export const HELP = message("ko", "help");
|
|
18
23
|
|
|
@@ -59,6 +64,34 @@ export async function runCommand(argv: readonly string[], deps: LaunchdDeps, ctx
|
|
|
59
64
|
if (import.meta.main) {
|
|
60
65
|
const argv = process.argv.slice(2);
|
|
61
66
|
if (argv[0] === "setup") process.exit(await setupCommand(argv.slice(1), () => systemSetupDeps(packageVersion()), HELP));
|
|
67
|
+
if (argv[0] === "security") {
|
|
68
|
+
const options = argv[1] === "import-baseline" ? parseImportArgs(argv.slice(2)) : "zigmini security import-baseline [--from <file>] [--dry-run]";
|
|
69
|
+
if (typeof options === "string") {
|
|
70
|
+
process.stderr.write(`${options}\n\n${HELP}`);
|
|
71
|
+
process.exit(2);
|
|
72
|
+
}
|
|
73
|
+
process.exit(await runImport(options, systemImportDeps()));
|
|
74
|
+
}
|
|
75
|
+
if (argv[0] === "scan") {
|
|
76
|
+
const deps = systemDeps();
|
|
77
|
+
const ctx = systemContext();
|
|
78
|
+
const lang = resolveLanguage(ctx.paths.home, ctx.env);
|
|
79
|
+
const disabled = scanDisabledNotice(ctx.paths.repoSecurityCheckFile, argv.slice(1), lang);
|
|
80
|
+
if (disabled !== null) {
|
|
81
|
+
deps.err(`${disabled}\n`);
|
|
82
|
+
process.exit(SCAN_DISABLED_EXIT);
|
|
83
|
+
}
|
|
84
|
+
const options = parseScanArgs(argv.slice(1), process.cwd(), lang);
|
|
85
|
+
process.exit(await runScan<ResolvedScanBaseline, ScanResult>(options, {
|
|
86
|
+
lang, out: deps.out, err: deps.err,
|
|
87
|
+
prepare: (scanOptions) => prepareScan(scanOptions, scanBaselinePaths(scanOptions, ctx.env)),
|
|
88
|
+
baseline: (scanOptions) => resolveScanBaseline(scanOptions, { env: ctx.env, lang, err: deps.err }),
|
|
89
|
+
revision: (baseline) => probeRevision(baseline, { env: ctx.env }),
|
|
90
|
+
scan: scanRepository,
|
|
91
|
+
syncHashes,
|
|
92
|
+
report: (report, scanOptions, result) => reportScan(report, scanOptions, result, deps.out),
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
62
95
|
if (argv[0] === "migrate" || argv[0] === "migrate-run") {
|
|
63
96
|
const home = process.env.HOME || "";
|
|
64
97
|
const options = argv[0] === "migrate" ? parseMigrateArgs(argv.slice(1), home) : parseMigrateRunArgs(argv.slice(1), home);
|
package/src/cli/messages.ts
CHANGED
|
@@ -7,8 +7,8 @@ export type Language = "en" | "ko";
|
|
|
7
7
|
export const LANGUAGE_FILE = "language";
|
|
8
8
|
export const LANGUAGE_QUESTION = "Language / 언어 [1] English [2] 한국어: ";
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
help: { en: "zigmini - personal AI agent gateway\n\nNew installation: run `zigmini setup` for permission consent, engine installation, configuration and startup.\n\nUsage: zigmini <command> [options]\n\nCommands:\n setup Set up the install home, engine, configuration and launchd services\n start Register and start the gateway and bridge with launchd\n stop [--uninstall] Stop services; --uninstall also removes this package's launchd plists\n restart [gateway|bridge] Restart services\n status Show launchd, health and permission consent status\n doctor [--json] [--fix] Check the installation; --fix restarts only unhealthy launchd agents\n pair [--apk] Show a one-use phone enrollment QR, or an APK download QR (--apk); valid for 10 minutes\n rotate-main [--restart] [--wait <seconds>] [--foreground]\n Replace the main conversation's engine session after its turn ends (default wait: 900s).\n Runs detached by default; results: <home>/logs/rotate-main.log.\n --restart restarts the gateway first; --foreground runs in this terminal.\n consent Show and record full-access permission consent; stop on refusal\n update [version] [--force] Install a version (default: latest), back up the DB and restart; roll back on failure.\n --force allows active turns; the gateway must be running.\n migrate [--from <old home>] [--checkout <repository>] [--accept-license] [--dry-run]\n Copy a Docker installation (~/" + LEGACY_HOME + "); keep the old directory unchanged.\n migrate-run [--from <old home>] [--checkout <repository>] [--access ... --domain ...] [--dry-run]\n Wait for idle, stop, back up, migrate, set up and check; restore the old run on failure.\n\nSetup options:\n --lang <en|ko> Language (saved in <home>/language)\n --non-interactive Do not ask setup questions\n --accept-license Accept the engine license\n --agent-dir <path> Dedicated agent directory instead of shared ~/.omo/agent\n --notion-token-file <path> Notion token file; no token disables the calendar\n --access <tailscale|lan|domain>, --domain <https://address>\n Choose external access\n --engine-only Run only the pinned engine installation step\n\nOptions:\n --version, -v Print the installed zigmini version\n --help, -h Print this help\n", ko: "zigmini - 개인 AI 에이전트 게이트웨이\n\n처음 설치했으면 먼저 `zigmini setup`을 실행하세요. setup이 권한 동의, 엔진 설치, 설정, 시작을 합니다.\n\n사용법: zigmini <명령> [옵션]\n\n명령:\n setup 설치합니다: 홈 폴더, 엔진, 설정, launchd 등록과 시작\n start 게이트웨이와 브리지를 launchd에 등록하고 시작합니다\n stop 게이트웨이와 브리지를 멈춥니다\n stop --uninstall 멈추고 이 패키지의 launchd plist를 모두 지웁니다\n restart [gateway|bridge] 다시 시작합니다\n status launchd 상태, health, 권한 동의 상태를 보여 줍니다\n doctor [--json] [--fix] 전체 상태를 점검합니다(--fix: 이상 있는 launchd 에이전트만 다시 띄움)\n pair 폰 등록 QR(유효 10분, 한 번 사용)을 터미널에 그립니다. 게이트웨이가 실행 중이어야 합니다\n pair --apk 앱(APK) 내려받기 QR(유효 10분)을 터미널에 그립니다\n rotate-main [--restart] [--wait <초>] [--foreground]\n main 대화를 새 엔진 세션으로 교체합니다. main 차례가 끝나길 기다린 뒤 보냅니다(기본 900초).\n 기본은 분리 실행이고 결과는 <홈>/logs/rotate-main.log에 남깁니다\n (--restart: 먼저 게이트웨이를 멈추고 다시 켭니다. --foreground: 이 터미널에서 실행)\n consent 풀 액세스 권한 안내를 보이고 동의를 기록합니다(거부하면 멈춤)\n update [버전] [--force] 새 버전으로 업데이트합니다(비우면 latest). DB 백업 뒤 설치·재시작하고, 실패하면 자동으로 되돌립니다\n (--force: 진행 중인 턴이 있어도 합니다. 게이트웨이가 실행 중이어야 합니다)\n migrate [--from <옛 홈>] [--checkout <저장소>] [--accept-license] [--dry-run]\n Docker 설치(~/" + LEGACY_HOME + ")를 복사해 옮깁니다. 옛 폴더는 바꾸지 않습니다\n migrate-run [--from <옛 홈>] [--checkout <저장소>] [--access ... --domain ...] [--dry-run]\n 유휴 대기 → 옛 실행 정지 → 백업 → migrate → setup → 확인, 실패하면 옛 실행으로 되돌립니다\n\nsetup 옵션:\n --lang <en|ko> Language / 언어 (saved in <home>/language)\n --non-interactive 묻지 않습니다\n --accept-license 엔진 라이선스에 동의합니다\n --agent-dir <경로> 공유 ~/.omo/agent 대신 쓸 전용 에이전트 폴더\n --notion-token-file <경로> Notion 토큰 파일(없으면 일정 기능 꺼짐)\n --access <tailscale|lan|domain>, --domain <https://주소>\n 외부 접속 선택\n --engine-only 엔진 설치 단계만 실행합니다\n\n옵션:\n --version, -v 설치된 zigmini 버전을 출력합니다\n --help, -h 이 도움말을 출력합니다\n" },
|
|
10
|
+
const BASE_MESSAGES = {
|
|
11
|
+
help: { en: "zigmini - personal AI agent gateway\n\nNew installation: run `zigmini setup` for permission consent, engine installation, configuration and startup.\n\nUsage: zigmini <command> [options]\n\nCommands:\n security import-baseline [--from <file>] [--dry-run] Import baseline terms through the running gateway; report counts only\n setup Set up the install home, engine, configuration and launchd services\n start Register and start the gateway and bridge with launchd\n stop [--uninstall] Stop services; --uninstall also removes this package's launchd plists\n restart [gateway|bridge] Restart services\n status Show launchd, health and permission consent status\n doctor [--json] [--fix] Check the installation; --fix restarts only unhealthy launchd agents\n pair [--apk] Show a one-use phone enrollment QR, or an APK download QR (--apk); valid for 10 minutes\n rotate-main [--restart] [--wait <seconds>] [--foreground]\n Replace the main conversation's engine session after its turn ends (default wait: 900s).\n Runs detached by default; results: <home>/logs/rotate-main.log.\n --restart restarts the gateway first; --foreground runs in this terminal.\n consent Show and record full-access permission consent; stop on refusal\n update [version] [--force] Install a version (default: latest), back up the DB and restart; roll back on failure.\n --force allows active turns; the gateway must be running.\n migrate [--from <old home>] [--checkout <repository>] [--accept-license] [--dry-run]\n Copy a Docker installation (~/" + LEGACY_HOME + "); keep the old directory unchanged.\n migrate-run [--from <old home>] [--checkout <repository>] [--access ... --domain ...] [--dry-run]\n Wait for idle, stop, back up, migrate, set up and check; restore the old run on failure.\n\nSetup options:\n --lang <en|ko> Language (saved in <home>/language)\n --non-interactive Do not ask setup questions\n --accept-license Accept the engine license\n --agent-dir <path> Dedicated agent directory instead of shared ~/.omo/agent\n --notion-token-file <path> Notion token file; no token disables the calendar\n --access <tailscale|lan|domain>, --domain <https://address>\n Choose external access\n --engine-only Run only the pinned engine installation step\n\nOptions:\n --version, -v Print the installed zigmini version\n --help, -h Print this help\n", ko: "zigmini - 개인 AI 에이전트 게이트웨이\n\n처음 설치했으면 먼저 `zigmini setup`을 실행하세요. setup이 권한 동의, 엔진 설치, 설정, 시작을 합니다.\n\n사용법: zigmini <명령> [옵션]\n\n명령:\n security import-baseline [--from <파일>] [--dry-run] 실행 중인 게이트웨이로 기준 용어를 가져오고 건수만 출력합니다\n setup 설치합니다: 홈 폴더, 엔진, 설정, launchd 등록과 시작\n start 게이트웨이와 브리지를 launchd에 등록하고 시작합니다\n stop 게이트웨이와 브리지를 멈춥니다\n stop --uninstall 멈추고 이 패키지의 launchd plist를 모두 지웁니다\n restart [gateway|bridge] 다시 시작합니다\n status launchd 상태, health, 권한 동의 상태를 보여 줍니다\n doctor [--json] [--fix] 전체 상태를 점검합니다(--fix: 이상 있는 launchd 에이전트만 다시 띄움)\n pair 폰 등록 QR(유효 10분, 한 번 사용)을 터미널에 그립니다. 게이트웨이가 실행 중이어야 합니다\n pair --apk 앱(APK) 내려받기 QR(유효 10분)을 터미널에 그립니다\n rotate-main [--restart] [--wait <초>] [--foreground]\n main 대화를 새 엔진 세션으로 교체합니다. main 차례가 끝나길 기다린 뒤 보냅니다(기본 900초).\n 기본은 분리 실행이고 결과는 <홈>/logs/rotate-main.log에 남깁니다\n (--restart: 먼저 게이트웨이를 멈추고 다시 켭니다. --foreground: 이 터미널에서 실행)\n consent 풀 액세스 권한 안내를 보이고 동의를 기록합니다(거부하면 멈춤)\n update [버전] [--force] 새 버전으로 업데이트합니다(비우면 latest). DB 백업 뒤 설치·재시작하고, 실패하면 자동으로 되돌립니다\n (--force: 진행 중인 턴이 있어도 합니다. 게이트웨이가 실행 중이어야 합니다)\n migrate [--from <옛 홈>] [--checkout <저장소>] [--accept-license] [--dry-run]\n Docker 설치(~/" + LEGACY_HOME + ")를 복사해 옮깁니다. 옛 폴더는 바꾸지 않습니다\n migrate-run [--from <옛 홈>] [--checkout <저장소>] [--access ... --domain ...] [--dry-run]\n 유휴 대기 → 옛 실행 정지 → 백업 → migrate → setup → 확인, 실패하면 옛 실행으로 되돌립니다\n\nsetup 옵션:\n --lang <en|ko> Language / 언어 (saved in <home>/language)\n --non-interactive 묻지 않습니다\n --accept-license 엔진 라이선스에 동의합니다\n --agent-dir <경로> 공유 ~/.omo/agent 대신 쓸 전용 에이전트 폴더\n --notion-token-file <경로> Notion 토큰 파일(없으면 일정 기능 꺼짐)\n --access <tailscale|lan|domain>, --domain <https://주소>\n 외부 접속 선택\n --engine-only 엔진 설치 단계만 실행합니다\n\n옵션:\n --version, -v 설치된 zigmini 버전을 출력합니다\n --help, -h 이 도움말을 출력합니다\n" },
|
|
12
12
|
permissionNotice: { en: "zigmini permission notice (notice version 1)\n\n1. The zigmini gateway runs the OMO agent with the full-access permission preset.\n2. The full-access preset allows every permission on every target. The agent does not ask for approval for each tool call.\n3. The agent has file read, write and edit tools, and a shell command (bash) tool.\n4. Shell commands run with this macOS account's permissions, without a sandbox or isolation.\n5. The agent uses the network for model API calls, web search (web_search) and web fetching (webfetch).\n6. The agent uses model accounts logged in through auth.json in the agent directory and consumes their usage allowance.\n7. The default agent directory is ~/.omo/agent. Other OMO instances using that directory share its login information (auth.json) and settings (settings.json).\n8. The gateway and bridge run as this macOS user's LaunchAgents. launchd restarts them after an error exit.\n9. Requests from enrolled phone apps run with the same full-access permissions.\n10. If you do not agree, zigmini installs nothing and does not start the gateway or bridge.", ko: "zigmini 권한 안내 (안내문 버전 1)\n\n1. zigmini 게이트웨이는 OMO 에이전트를 권한 프리셋 full-access로 실행합니다.\n2. full-access 프리셋은 모든 권한, 모든 대상에 '허용' 규칙 하나만 둡니다. 그래서 에이전트는 도구를 쓸 때마다 사용자 승인을 받지 않습니다.\n3. 에이전트에는 파일 읽기(read), 쓰기(write), 고치기(edit) 도구와 셸 명령(bash) 도구가 있습니다.\n4. 셸 명령은 샌드박스 없이 이 macOS 사용자 계정의 권한으로 실행됩니다. 격리는 없습니다.\n5. 에이전트는 네트워크를 씁니다: 모델 API 호출, 웹 검색(web_search), 웹 가져오기(webfetch).\n6. 에이전트는 에이전트 폴더의 auth.json에 로그인한 모델 계정을 쓰고, 그 계정의 사용량을 소비합니다.\n7. 기본 에이전트 폴더는 ~/.omo/agent입니다. 같은 폴더를 쓰는 다른 OMO와 로그인 정보(auth.json)와 설정(settings.json)을 같이 씁니다.\n8. 게이트웨이와 브리지는 이 macOS 사용자의 LaunchAgent로 실행되고, 오류로 끝나면 launchd가 다시 시작합니다.\n9. 등록한 폰 앱이 보낸 요청도 같은 full-access 권한으로 실행됩니다.\n10. 동의하지 않으면 zigmini는 아무것도 설치하지 않고, 게이트웨이와 브리지는 시작하지 않습니다." },
|
|
13
13
|
unknownOption: { en: "Unknown option: {flag}", ko: "알 수 없는 옵션: {flag}" },
|
|
14
14
|
missingValue: { en: "{flag} requires a value", ko: "{flag} 에 값이 필요합니다" },
|
|
@@ -117,6 +117,14 @@ export const MESSAGES = {
|
|
|
117
117
|
schemaRefused: { en: "The bridge was not started. Gateway health ({url}) reports omo=schema_too_new.\n", ko: "브리지를 시작하지 않았습니다. 게이트웨이 health({url})가 omo=schema_too_new를 보입니다.\n" },
|
|
118
118
|
} as const satisfies Record<string, Readonly<Record<Language, string>>>;
|
|
119
119
|
|
|
120
|
+
export const MESSAGES = {
|
|
121
|
+
...BASE_MESSAGES,
|
|
122
|
+
help: {
|
|
123
|
+
en: BASE_MESSAGES.help.en.replace("Commands:\n", "Commands:\n scan [<repo>] Scan a repository for security findings\n"),
|
|
124
|
+
ko: BASE_MESSAGES.help.ko.replace("명령:\n", "명령:\n scan [<repo>] 저장소의 보안 발견 항목을 검사합니다\n"),
|
|
125
|
+
},
|
|
126
|
+
} as const satisfies Record<string, Readonly<Record<Language, string>>>;
|
|
127
|
+
|
|
120
128
|
export type MessageKey = keyof typeof MESSAGES;
|
|
121
129
|
export function message(lang: Language, key: MessageKey, values: Readonly<Record<string, string | number>> = {}): string {
|
|
122
130
|
return MESSAGES[key][lang].replace(/\{(\w+)\}/g, (placeholder, name: string) => String(values[name] ?? placeholder));
|
package/src/cli/rotate-main.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from
|
|
|
3
3
|
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { agentDir } from "../model-accounts.ts";
|
|
5
5
|
import { ROTATION_HELD_MESSAGE } from "../protocol.ts";
|
|
6
|
+
import { isTextOf } from "../server-text.ts";
|
|
6
7
|
import { type LaunchdContext, type LaunchdDeps, ownProcess, parseConfigEnv, startCommand, stopCommand } from "./launchd.ts";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -297,7 +298,7 @@ export function socketRotate(url: string, timeoutMs: number): Promise<RotateOutc
|
|
|
297
298
|
end({ kind: "rotated", before, from: data.from, to: data.to });
|
|
298
299
|
} else if (message.type === "error") {
|
|
299
300
|
const text = typeof data.message === "string" ? data.message : JSON.stringify(data);
|
|
300
|
-
end(text
|
|
301
|
+
end(isTextOf(text, ROTATION_HELD_MESSAGE) ? { kind: "held", message: text } : { kind: "error", message: text });
|
|
301
302
|
}
|
|
302
303
|
};
|
|
303
304
|
ws.onerror = () => end({ kind: "error", message: "WebSocket 연결 오류" });
|