zigmini 1.0.0-beta.2 → 1.1.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.
Files changed (141) hide show
  1. package/README.ko.md +36 -3
  2. package/README.md +36 -3
  3. package/assets/agent/gateway-request-rules.md +1 -1
  4. package/assets/agent/gateway-rules.md +6 -1
  5. package/assets/android/zigmini.apk +0 -0
  6. package/assets/android/zigmini.apk.json +3 -3
  7. package/assets/build.json +1 -1
  8. package/assets/document-reader/document-reader +0 -0
  9. package/assets/document-reader/host-selector +0 -0
  10. package/assets/document-reader/manifest.json +27 -0
  11. package/docs/manual.ko.md +120 -11
  12. package/docs/manual.md +123 -12
  13. package/host/document-reader/DocumentRead.swift +370 -0
  14. package/host/document-reader/HostSelector.swift +192 -0
  15. package/host/document-reader/Protocol.swift +647 -0
  16. package/host/document-reader/main.swift +22 -0
  17. package/host/omo-bridge/agent-aside-tool.ts +119 -0
  18. package/host/omo-bridge/agent-aside.ts +522 -0
  19. package/host/omo-bridge/agent-collaboration-files.ts +282 -0
  20. package/host/omo-bridge/agent-collaboration.ts +385 -0
  21. package/host/omo-bridge/agent-control.ts +400 -28
  22. package/host/omo-bridge/agent-execution.sb +42 -0
  23. package/host/omo-bridge/agent-execution.ts +158 -0
  24. package/host/omo-bridge/agent-tool-adapters.ts +388 -0
  25. package/host/omo-bridge/agent-tool-permissions.ts +96 -0
  26. package/host/omo-bridge/aside-tabs.ts +41 -0
  27. package/host/omo-bridge/attachment-read.ts +100 -0
  28. package/host/omo-bridge/bridge.ts +10 -0
  29. package/host/omo-bridge/managed-call.ts +46 -0
  30. package/host/omo-bridge/task-control.ts +39 -5
  31. package/host/omo-check/patches/attachments-293/README.md +138 -0
  32. package/host/omo-check/patches/attachments-293/document-content.js +234 -0
  33. package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
  34. package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
  35. package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
  36. package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
  37. package/host/omo-check/patches/omo-5.1.51-chatgpt-terra.patch +21 -0
  38. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +44 -6
  39. package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
  40. package/package.json +5 -3
  41. package/src/agent/api.ts +345 -20
  42. package/src/agent/artifacts.ts +196 -0
  43. package/src/agent/aside-target-routes.ts +369 -0
  44. package/src/agent/aside-targets.ts +215 -0
  45. package/src/agent/collaboration-admission.ts +47 -0
  46. package/src/agent/collaboration-read.ts +117 -0
  47. package/src/agent/collaboration-session.ts +196 -0
  48. package/src/agent/collaboration-types.ts +258 -0
  49. package/src/agent/collaboration.ts +399 -0
  50. package/src/agent/definition.ts +65 -3
  51. package/src/agent/fields.ts +9 -3
  52. package/src/agent/file-core.ts +325 -0
  53. package/src/agent/files.ts +87 -8
  54. package/src/agent/list.ts +3 -3
  55. package/src/agent/managed-call.ts +144 -0
  56. package/src/agent/tool-catalog.ts +184 -0
  57. package/src/agent/tool-delivery.ts +359 -0
  58. package/src/agent/tool-permission-types.ts +81 -0
  59. package/src/agent/tool-permissions.ts +1366 -0
  60. package/src/attachments/api.ts +161 -0
  61. package/src/attachments/document-reader.ts +325 -0
  62. package/src/attachments/host-selector.ts +410 -0
  63. package/src/attachments/managed-binding.ts +83 -0
  64. package/src/attachments/prepare.ts +214 -0
  65. package/src/attachments/selector-port.ts +28 -0
  66. package/src/attachments/service.ts +729 -0
  67. package/src/attachments/store.ts +698 -0
  68. package/src/attachments/types.ts +123 -0
  69. package/src/cli/doctor.ts +30 -10
  70. package/src/decisions.ts +109 -10
  71. package/src/engine/apply-patch.ts +21 -0
  72. package/src/engine/compat.json +2 -1
  73. package/src/engine/install.ts +45 -4
  74. package/src/engine/sandbox-cell.ts +2 -0
  75. package/src/gc.ts +33 -225
  76. package/src/housekeeping/api.ts +163 -0
  77. package/src/housekeeping/audit.ts +61 -0
  78. package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
  79. package/src/housekeeping/collectors/agent-drafts.ts +43 -0
  80. package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
  81. package/src/housekeeping/collectors/agent-trash.ts +28 -0
  82. package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
  83. package/src/housekeeping/collectors/db-backup.ts +25 -0
  84. package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
  85. package/src/housekeeping/collectors/docker-image.ts +135 -0
  86. package/src/housekeeping/collectors/omo-version.ts +32 -0
  87. package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
  88. package/src/housekeeping/collectors/reconcile-work.ts +55 -0
  89. package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
  90. package/src/housekeeping/collectors/shared.ts +149 -0
  91. package/src/housekeeping/collectors/temp-dir.ts +28 -0
  92. package/src/housekeeping/collectors/update-file.ts +25 -0
  93. package/src/housekeeping/collectors/worktree.ts +193 -0
  94. package/src/housekeeping/engine.ts +125 -0
  95. package/src/housekeeping/reconcile/work.ts +130 -0
  96. package/src/housekeeping/registry.ts +63 -0
  97. package/src/housekeeping/trash.ts +59 -0
  98. package/src/housekeeping/types.ts +31 -0
  99. package/src/hub.ts +1095 -41
  100. package/src/main.ts +230 -31
  101. package/src/message-references.ts +14 -5
  102. package/src/profile-prompt.ts +13 -1
  103. package/src/profile.ts +13 -5
  104. package/src/protocol.ts +41 -6
  105. package/src/security-allow.ts +79 -2
  106. package/src/security-check.ts +1 -1
  107. package/src/store.ts +1946 -23
  108. package/src/test-crash.ts +20 -0
  109. package/src/usage.ts +18 -7
  110. package/src/work-watch.ts +55 -7
  111. package/src/work.ts +192 -3
  112. package/web/DESIGN.md +35 -1
  113. package/web/agents.d.ts +189 -12
  114. package/web/agents.js +898 -39
  115. package/web/app.js +2634 -210
  116. package/web/attachments.d.ts +91 -0
  117. package/web/attachments.js +422 -0
  118. package/web/collaboration.d.ts +87 -0
  119. package/web/collaboration.js +290 -0
  120. package/web/decisions.d.ts +39 -2
  121. package/web/decisions.js +354 -2
  122. package/web/gc.d.ts +26 -5
  123. package/web/gc.js +113 -13
  124. package/web/i18n/agents.d.ts +4 -0
  125. package/web/i18n/agents.js +200 -8
  126. package/web/i18n/attachments.js +78 -0
  127. package/web/i18n/decisions.d.ts +4 -0
  128. package/web/i18n/decisions.js +68 -0
  129. package/web/i18n/gc.d.ts +4 -0
  130. package/web/i18n/gc.js +184 -2
  131. package/web/i18n/main.js +80 -8
  132. package/web/i18n/tour.d.ts +4 -0
  133. package/web/i18n/tour.js +37 -0
  134. package/web/index.html +20 -3
  135. package/web/profile.d.ts +2 -2
  136. package/web/profile.js +16 -10
  137. package/web/style.css +342 -21
  138. package/web/tooltip.d.ts +2 -0
  139. package/web/tooltip.js +12 -0
  140. package/web/tour.d.ts +57 -0
  141. package/web/tour.js +450 -0
@@ -0,0 +1,28 @@
1
+ import { join } from "node:path";
2
+ import { agentTrashTimestamp } from "../../agent/files.ts";
3
+ import { say, type Text } from "../../server-text.ts";
4
+ import { AGENT_TRASH_MIN_AGE_MS, DAY_MS, type GcCandidate, gcCollector, type GcDeps, type GcRoots } from "./shared.ts";
5
+
6
+ /** Agent folders in agents/.trash, older than AGENT_TRASH_MIN_AGE_MS by name timestamp or mtime. */
7
+
8
+ export const REASON_AGENT_TRASH: Text = { en: "Archived agent folder older than {days} days", ko: "{days}일 지난 에이전트 보관 폴더" };
9
+
10
+ export function agentTrashCandidates(roots: GcRoots, deps: GcDeps): GcCandidate[] {
11
+ const trash = join(roots.agents, ".trash");
12
+ const entries = deps.list(trash);
13
+ if (entries === null) return [];
14
+ const now = deps.now();
15
+ const out: GcCandidate[] = [];
16
+ for (const entry of entries) {
17
+ if (entry.type !== "dir") continue;
18
+ const namedAt = agentTrashTimestamp(entry.name);
19
+ if (namedAt !== null && !Number.isFinite(namedAt)) continue;
20
+ const ageMs = now - (namedAt ?? entry.mtimeMs);
21
+ if (ageMs <= AGENT_TRASH_MIN_AGE_MS) continue;
22
+ const target = join(trash, entry.name);
23
+ out.push({ id: `agent-trash:${target}`, kind: "agent-trash", target, reason: say(REASON_AGENT_TRASH, { days: AGENT_TRASH_MIN_AGE_MS / DAY_MS }), ageMs, bytes: null });
24
+ }
25
+ return out;
26
+ }
27
+
28
+ export const agentTrash = gcCollector({ kind: "agent-trash", npmOnly: true, root: (roots) => join(roots.agents, ".trash"), candidates: agentTrashCandidates });
@@ -0,0 +1,35 @@
1
+ import { say, type Text } from "../../server-text.ts";
2
+ import type { Candidate, Collector } from "../types.ts";
3
+
4
+ export interface AttachmentBusy { readonly grants: number; readonly readers: number; readonly preparing: number; readonly uncertain: number }
5
+ export interface AttachmentCleanupRow { readonly attachmentId: string; readonly target: string; readonly state: string; readonly bytes: number; readonly busy: AttachmentBusy }
6
+ export interface AttachmentCleanupDeps {
7
+ readonly list: () => readonly AttachmentCleanupRow[];
8
+ readonly cleanup: (attachmentId: string) => Promise<void>;
9
+ }
10
+
11
+ const REASON: Text = { en: "Attachment no longer in use ({state})", ko: "더 이상 쓰지 않는 첨부 파일입니다({state})" };
12
+ const isBusy = (busy: AttachmentBusy) => busy.grants + busy.readers + busy.preparing + busy.uncertain > 0;
13
+ const candidate = (row: AttachmentCleanupRow): Candidate => ({ id: `attachment-cleanup:${row.attachmentId}`, collector: "attachment-cleanup", target: row.target,
14
+ reason: say(REASON, { state: row.state }), ageMs: null, bytes: row.bytes, details: { attachmentId: row.attachmentId, busy: row.busy } });
15
+
16
+ /** Only a selected id moves to the Trash: the operator confirmation keeps every scan read-only and time plays no part. */
17
+ export const attachmentCleanup: Collector<AttachmentCleanupDeps> = {
18
+ name: "attachment-cleanup",
19
+ role: "gc",
20
+ triggers: ["web_open", "doctor"],
21
+ confirmation: "operator",
22
+ reversibility: "trash",
23
+ find: async deps => deps.list().map(candidate),
24
+ judge: async (c, deps) => {
25
+ const row = deps.list().find(item => item.attachmentId === c.details?.attachmentId);
26
+ if (!row) return { kind: "keep", why: "no_longer_a_candidate" };
27
+ if (isBusy(row.busy)) return { kind: "keep", why: "in_use_by_process" };
28
+ return { kind: "act", why: "rule_holds", evidence: { state: row.state } };
29
+ },
30
+ act: async (c, deps) => {
31
+ try { await deps.cleanup(String(c.details?.attachmentId)); }
32
+ catch (error) { return { ok: false, method: "trash", error: error instanceof Error ? error.message : String(error) }; }
33
+ return { ok: true, method: "trash" };
34
+ },
35
+ };
@@ -0,0 +1,25 @@
1
+ import { join } from "node:path";
2
+ import { say, type Text } from "../../server-text.ts";
3
+ import { BACKUP_KEEP_MS, DAY_MS, type GcCandidate, gcCollector, type GcDeps, type GcRoots, KEEP_BACKUPS } from "./shared.ts";
4
+
5
+ /** `*.sqlite` file in the backups folder, not among the newest KEEP_BACKUPS and older than BACKUP_KEEP_MS. */
6
+
7
+ const REASON_BACKUP: Text = { en: "Outside the newest {keep} and older than {days} days", ko: "최근 {keep}개 밖이고 {days}일 지남" };
8
+
9
+ export function backupCandidates(roots: GcRoots, deps: GcDeps): GcCandidate[] {
10
+ const entries = deps.list(roots.backups);
11
+ if (entries === null) return [];
12
+ const backups = entries.filter((entry) => entry.type === "file" && entry.name.endsWith(".sqlite")).sort((a, b) => b.mtimeMs - a.mtimeMs);
13
+ const now = deps.now();
14
+ const out: GcCandidate[] = [];
15
+ for (const [index, entry] of backups.entries()) {
16
+ const ageMs = now - entry.mtimeMs;
17
+ if (index < KEEP_BACKUPS) continue;
18
+ if (ageMs <= BACKUP_KEEP_MS) continue;
19
+ const target = join(roots.backups, entry.name);
20
+ out.push({ id: `db-backup:${target}`, kind: "db-backup", target, reason: say(REASON_BACKUP, { keep: KEEP_BACKUPS, days: BACKUP_KEEP_MS / DAY_MS }), ageMs, bytes: null });
21
+ }
22
+ return out;
23
+ }
24
+
25
+ export const dbBackup = gcCollector({ kind: "db-backup", npmOnly: false, root: (roots) => roots.backups, candidates: backupCandidates });
@@ -0,0 +1,29 @@
1
+ import type { Collector } from "../types.ts";
2
+ import { type DockerDeps, dockerUsable, jsonLines, parseDockerSize } from "./docker-image.ts";
3
+
4
+ /** Info only: the build cache is shared with other projects, so its size is shown and it is never acted on. */
5
+ export const dockerBuildCache: Collector<DockerDeps> = {
6
+ name: "docker-build-cache",
7
+ role: "gc",
8
+ triggers: ["web_open", "doctor"],
9
+ confirmation: "operator",
10
+ reversibility: "irreversible",
11
+
12
+ async find(deps) {
13
+ if (!(await dockerUsable(deps))) return [];
14
+ const out = await deps.run(["system", "df", "--format", "{{json .}}"]);
15
+ if (!out || out.code !== 0) return [];
16
+ const row = jsonLines(out.stdout).find((r) => r.Type === "Build Cache");
17
+ if (!row) return [];
18
+ const bytes = typeof row.Size === "string" ? parseDockerSize(row.Size) : null;
19
+ return [{ id: "docker-build-cache", collector: "docker-build-cache", target: "docker build cache", reason: "shared_build_cache", ageMs: null, bytes }];
20
+ },
21
+
22
+ async judge() {
23
+ return { kind: "keep", why: "shared_with_other_projects" };
24
+ },
25
+
26
+ async act() {
27
+ return { ok: false, method: "docker_rmi", error: "info_only" };
28
+ },
29
+ };
@@ -0,0 +1,135 @@
1
+ import type { InstallKind } from "../../runtime/install-kind.ts";
2
+ import type { Candidate, Collector, Verdict } from "../types.ts";
3
+
4
+ /** Runs `docker <args>` as an argument array (no shell). `null` = no docker CLI on PATH. */
5
+ export type DockerRun = (args: readonly string[]) => Promise<{ code: number; stdout: string; stderr: string } | null>;
6
+ export interface DockerDeps { installKind: InstallKind; run: DockerRun; now: () => number }
7
+
8
+ export const REPOSITORY = "zigmini-gateway";
9
+ const PROTECTED_TAGS = new Set(["dev", "previous", "<none>"]);
10
+ const TAG = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
11
+ const UNITS: Record<string, number> = { b: 1, kb: 1e3, mb: 1e6, gb: 1e9, tb: 1e12 };
12
+
13
+ /** Bug 324: a hung Docker must not hold GET /api/housekeeping past the web server's idle timeout. */
14
+ export const DOCKER_TIMEOUT_MS = 5_000;
15
+ export const DOCKER_TIMEOUT_CODE = 124;
16
+
17
+ /**
18
+ * Runs the docker CLI in its own process group. After `timeoutMs` the whole group gets SIGKILL (no orphan
19
+ * docker child stays behind) and the call answers exit code 124, which every caller reads as "docker unusable".
20
+ */
21
+ export function dockerRunner(bin = "docker", timeoutMs = DOCKER_TIMEOUT_MS): DockerRun {
22
+ return async (args) => {
23
+ let proc;
24
+ try {
25
+ proc = Bun.spawn([bin, ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe", detached: true });
26
+ } catch {
27
+ return null;
28
+ }
29
+ let timedOut = false;
30
+ const timer = setTimeout(() => {
31
+ timedOut = true;
32
+ try { process.kill(-proc.pid, "SIGKILL"); } catch {}
33
+ }, timeoutMs);
34
+ try {
35
+ const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
36
+ return timedOut ? { code: DOCKER_TIMEOUT_CODE, stdout: "", stderr: "docker_timeout" } : { code, stdout, stderr };
37
+ } finally {
38
+ clearTimeout(timer);
39
+ }
40
+ };
41
+ }
42
+
43
+ export const systemDockerRun: DockerRun = dockerRunner();
44
+
45
+ /** Docker's human sizes are decimal (`1.5GB` = 1.5e9 bytes). */
46
+ export function parseDockerSize(text: string): number | null {
47
+ const m = /^([\d.]+)\s*([kMGT]?B)$/i.exec(text.trim());
48
+ const unit = m && UNITS[m[2]!.toLowerCase()];
49
+ return m && unit ? Math.round(Number(m[1]) * unit) : null;
50
+ }
51
+
52
+ export function jsonLines(stdout: string): Record<string, unknown>[] {
53
+ const out: Record<string, unknown>[] = [];
54
+ for (const line of stdout.split("\n")) {
55
+ if (!line.trim()) continue;
56
+ try {
57
+ const value = JSON.parse(line);
58
+ if (value && typeof value === "object") out.push(value as Record<string, unknown>);
59
+ } catch {}
60
+ }
61
+ return out;
62
+ }
63
+
64
+ export async function dockerUsable(deps: DockerDeps): Promise<boolean> {
65
+ if (deps.installKind !== "npm") return false;
66
+ const info = await deps.run(["info"]);
67
+ return info !== null && info.code === 0;
68
+ }
69
+
70
+ interface Image { tag: string; id: string; bytes: number | null; createdAt: number | null }
71
+
72
+ async function images(deps: DockerDeps): Promise<Image[] | null> {
73
+ const out = await deps.run(["images", REPOSITORY, "--format", "{{json .}}"]);
74
+ if (!out || out.code !== 0) return null;
75
+ return jsonLines(out.stdout)
76
+ .filter((i) => i.Repository === REPOSITORY && typeof i.Tag === "string" && typeof i.ID === "string")
77
+ .map((i) => ({ tag: i.Tag as string, id: i.ID as string, bytes: typeof i.Size === "string" ? parseDockerSize(i.Size) : null, createdAt: created(i.CreatedAt) }));
78
+ }
79
+
80
+ /** `2026-10-04 16:00:00 +0900 KST` -> epoch ms. */
81
+ function created(value: unknown): number | null {
82
+ if (typeof value !== "string") return null;
83
+ const m = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}) ([+-]\d{2})(\d{2})/.exec(value);
84
+ if (!m) return null;
85
+ const at = Date.parse(`${m[1]}T${m[2]}${m[3]}:${m[4]}`);
86
+ return Number.isNaN(at) ? null : at;
87
+ }
88
+
89
+ export const dockerImage: Collector<DockerDeps> = {
90
+ name: "docker-image",
91
+ role: "gc",
92
+ triggers: ["web_open", "doctor"],
93
+ confirmation: "operator",
94
+ reversibility: "irreversible",
95
+
96
+ async find(deps) {
97
+ if (!(await dockerUsable(deps))) return [];
98
+ const list = await images(deps);
99
+ if (!list) return [];
100
+ return list
101
+ .filter((i) => !PROTECTED_TAGS.has(i.tag) && TAG.test(i.tag))
102
+ .map((i): Candidate => ({
103
+ id: `docker-image:${i.tag}`,
104
+ collector: "docker-image",
105
+ target: `${REPOSITORY}:${i.tag}`,
106
+ reason: "old_tag",
107
+ ageMs: i.createdAt === null ? null : Math.max(0, deps.now() - i.createdAt),
108
+ bytes: i.bytes,
109
+ details: { tag: i.tag, imageId: i.id },
110
+ }));
111
+ },
112
+
113
+ async judge(c, deps): Promise<Verdict> {
114
+ const tag = c.details?.tag;
115
+ const imageId = c.details?.imageId;
116
+ if (typeof tag !== "string" || typeof imageId !== "string" || !TAG.test(tag)) return { kind: "keep", why: "unreadable_candidate" };
117
+ if (PROTECTED_TAGS.has(tag)) return { kind: "keep", why: "protected_tag" };
118
+ if (!(await dockerUsable(deps))) return { kind: "keep", why: "docker_unavailable" };
119
+ const list = await images(deps);
120
+ if (!list) return { kind: "keep", why: "docker_images_failed" };
121
+ if (!list.some((i) => i.tag === tag && i.id === imageId)) return { kind: "keep", why: "image_changed" };
122
+ const ps = await deps.run(["ps", "-a", "--filter", `ancestor=${imageId}`, "-q"]);
123
+ if (!ps || ps.code !== 0) return { kind: "keep", why: "docker_ps_failed" };
124
+ if (ps.stdout.trim() !== "") return { kind: "keep", why: "in_use" };
125
+ return { kind: "act", why: "unused_old_tag", evidence: { tag, imageId, containers: 0 } };
126
+ },
127
+
128
+ async act(c, deps) {
129
+ const tag = c.details?.tag;
130
+ if (typeof tag !== "string" || !TAG.test(tag) || PROTECTED_TAGS.has(tag)) return { ok: false, method: "docker_rmi", error: "bad_tag" };
131
+ const out = await deps.run(["image", "rm", `${REPOSITORY}:${tag}`]);
132
+ if (!out) return { ok: false, method: "docker_rmi", error: "docker_missing" };
133
+ return out.code === 0 ? { ok: true, method: "docker_rmi" } : { ok: false, method: "docker_rmi", error: out.stderr.trim() || `exit_${out.code}` };
134
+ },
135
+ };
@@ -0,0 +1,32 @@
1
+ import { join, sep } from "node:path";
2
+ import { say, type Text } from "../../server-text.ts";
3
+ import { type GcCandidate, gcCollector, type GcDeps, type GcRoots, linkTarget } from "./shared.ts";
4
+
5
+ /** Folder in omo/ that is not the target of omo/current, not the previous engine, and no process runs from it. */
6
+
7
+ const REASON_OMO: Text = { en: "Not the current ({current}) or previous version, and no process is running from it", ko: "현재({current})·직전 버전이 아니고 실행 중인 프로세스 없음" };
8
+
9
+ export async function omoCandidates(roots: GcRoots, deps: GcDeps): Promise<GcCandidate[]> {
10
+ const current = linkTarget(roots.omo, join(roots.omo, "current"), deps);
11
+ if (current === null) return [];
12
+ const entries = deps.list(roots.omo);
13
+ if (entries === null) return [];
14
+ const folders = entries.filter((entry) => entry.type === "dir" && !entry.name.startsWith(".") && entry.name !== "current");
15
+ const recorded = deps.readText(join(roots.update, "prev-engine"))?.trim() ?? "";
16
+ // The previous engine is what an update rollback relinks (Todo 23). Without a record, the newest other folder stands in.
17
+ const others = folders.filter((entry) => entry.name !== current).sort((a, b) => b.mtimeMs - a.mtimeMs);
18
+ const previous = recorded !== "" ? recorded : others[0]?.name ?? null;
19
+ const commands = await deps.commands();
20
+ if (commands === null) return [];
21
+ const now = deps.now();
22
+ const out: GcCandidate[] = [];
23
+ for (const entry of others) {
24
+ if (entry.name === previous) continue;
25
+ const target = join(roots.omo, entry.name);
26
+ if (commands.some((command) => command.includes(`${target}${sep}`))) continue;
27
+ out.push({ id: `omo-version:${target}`, kind: "omo-version", target, reason: say(REASON_OMO, { current }), ageMs: now - entry.mtimeMs, bytes: null });
28
+ }
29
+ return out;
30
+ }
31
+
32
+ export const omoVersion = gcCollector({ kind: "omo-version", npmOnly: true, root: (roots) => roots.omo, candidates: omoCandidates });
@@ -0,0 +1,205 @@
1
+ import { lstatSync, readlinkSync } from "node:fs";
2
+ import { tmpdir, userInfo } from "node:os";
3
+ import { isAbsolute, join, resolve, sep } from "node:path";
4
+ import type { InstallKind } from "../../runtime/install-kind.ts";
5
+ import { say, type Text } from "../../server-text.ts";
6
+ import type { ActResult, Candidate, Collector, Verdict } from "../types.ts";
7
+
8
+ /**
9
+ * Orphaned test processes (bug 65 type: fake docker and other helpers a test left running after its temp root was removed).
10
+ * A candidate is a process of the current user, not pid 1, whose command line names a `$TMPDIR/zgw-*` or `$TMPDIR/zigmini-*`
11
+ * folder. It is acted on only when its parent is launchd (ppid 1), every named temp folder is gone (stat ENOENT), and it is
12
+ * not the gateway, bridge or OMO of this home. ps, stat or launchctl failure -> keep. npm installs only. SIGTERM only.
13
+ */
14
+
15
+ export const NAME = "orphan-test-process";
16
+ const REASON: Text = { en: "Test process with no parent whose temp folder is gone", ko: "부모가 없고 임시 폴더가 사라진 시험 프로세스" };
17
+ export const LAUNCHD_LABELS = ["com.zigmini.gateway", "com.zigmini.bridge"] as const;
18
+
19
+ type MaybeAsync<T> = T | Promise<T>;
20
+ export interface OrphanProcess { readonly pid: number; readonly ppid: number; readonly user: string; readonly args: string }
21
+ export interface OrphanDeps {
22
+ readonly kind: InstallKind;
23
+ readonly tmp: string;
24
+ readonly user: string;
25
+ /** Every process, or null when ps failed. */
26
+ readonly processes: () => MaybeAsync<readonly OrphanProcess[] | null>;
27
+ /** false only for ENOENT; null when stat failed otherwise. */
28
+ readonly exists: (path: string) => MaybeAsync<boolean | null>;
29
+ /** Gateway, bridge and OMO pids of this home (and this process), or null when they cannot be read. */
30
+ readonly protectedPids: () => MaybeAsync<readonly number[] | null>;
31
+ /** Sends SIGTERM; throws when the signal fails. */
32
+ readonly terminate: (pid: number) => void;
33
+ }
34
+
35
+ function escape(text: string): string {
36
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
37
+ }
38
+
39
+ /** Temp folders (`<tmp>/zgw-*`, `<tmp>/zigmini-*`, also through /private on macOS) named in a command line. */
40
+ export function tempPaths(args: string, tmp: string): string[] {
41
+ const root = tmp.endsWith(sep) ? tmp : tmp + sep;
42
+ const roots = root.startsWith("/var/") || root.startsWith("/tmp/") ? [root, `/private${root}`] : [root];
43
+ const found = new Set<string>();
44
+ for (const prefix of roots) {
45
+ const pattern = new RegExp(`(?:^|[\\s='"])(${escape(prefix)}(?:zgw-|zigmini-)[^/\\s'"]+)`, "g");
46
+ for (const match of args.matchAll(pattern)) if (match[1] !== undefined) found.add(match[1]);
47
+ }
48
+ return [...found];
49
+ }
50
+
51
+ function pidOf(c: Candidate): number {
52
+ return Number(c.id.slice(NAME.length + 1));
53
+ }
54
+
55
+ function keep(why: string): Verdict {
56
+ return { kind: "keep", why };
57
+ }
58
+
59
+ export const orphanTestProcess: Collector<OrphanDeps> = {
60
+ name: NAME,
61
+ role: "gc",
62
+ triggers: ["start", "update_end", "web_open", "doctor"],
63
+ confirmation: "auto",
64
+ reversibility: "process_none",
65
+
66
+ async find(deps) {
67
+ if (deps.kind !== "npm") return [];
68
+ const processes = await deps.processes();
69
+ if (processes === null) return [];
70
+ const out: Candidate[] = [];
71
+ for (const p of processes) {
72
+ if (p.pid <= 1 || p.user !== deps.user) continue;
73
+ const paths = tempPaths(p.args, deps.tmp);
74
+ if (paths.length === 0) continue;
75
+ out.push({ id: `${NAME}:${p.pid}`, collector: NAME, target: `pid ${p.pid}: ${p.args.slice(0, 200)}`, reason: say(REASON), ageMs: null, bytes: null, details: { pid: p.pid, ppid: p.ppid, args: p.args, paths } });
76
+ }
77
+ return out;
78
+ },
79
+
80
+ async judge(c, deps) {
81
+ if (deps.kind !== "npm") return keep("not_npm_install");
82
+ const pid = pidOf(c);
83
+ const processes = await deps.processes();
84
+ if (processes === null) return keep("ps_unreadable");
85
+ const live = processes.find((p) => p.pid === pid);
86
+ if (live === undefined) return keep("gone");
87
+ if (live.args !== c.details?.args) return keep("pid_reused");
88
+ if (live.user !== deps.user) return keep("other_user");
89
+ if (live.ppid !== 1) return keep("has_parent");
90
+ const paths = tempPaths(live.args, deps.tmp);
91
+ if (paths.length === 0) return keep("no_temp_path");
92
+ const guarded = await deps.protectedPids();
93
+ if (guarded === null) return keep("protected_unreadable");
94
+ if (guarded.includes(pid)) return keep("protected");
95
+ for (const path of paths) {
96
+ const exists = await deps.exists(path);
97
+ if (exists === null) return keep("stat_failed");
98
+ if (exists) return keep("temp_path_exists");
99
+ }
100
+ return { kind: "act", why: "orphaned_test_process", evidence: { pid, ppid: live.ppid, user: live.user, args: live.args, missing: paths } };
101
+ },
102
+
103
+ async act(c, deps): Promise<ActResult> {
104
+ try {
105
+ deps.terminate(pidOf(c));
106
+ return { ok: true, method: "sigterm" };
107
+ } catch (error) {
108
+ return { ok: false, method: "sigterm", error: error instanceof Error ? error.message : String(error) };
109
+ }
110
+ },
111
+ };
112
+
113
+ /** `ps -o pid=,ppid=,user=,args=` output; any line that does not parse makes the whole list unreadable (null). */
114
+ export function parseProcesses(text: string): OrphanProcess[] | null {
115
+ const out: OrphanProcess[] = [];
116
+ for (const line of text.split("\n")) {
117
+ if (line.trim() === "") continue;
118
+ const match = /^\s*(\d+)\s+(\d+)\s+(\S+)(?:\s+(.*))?$/.exec(line);
119
+ if (match === null) return null;
120
+ out.push({ pid: Number(match[1]), ppid: Number(match[2]), user: match[3] ?? "", args: match[4] ?? "" });
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /** The `"PID" = N;` entry of `launchctl list <label>`; null when the job is not running. */
126
+ export function parseLaunchctlPid(text: string): number | null {
127
+ const match = /"PID"\s*=\s*(\d+);/.exec(text);
128
+ return match === null ? null : Number(match[1]);
129
+ }
130
+
131
+ export type Run = (argv: readonly string[]) => Promise<{ code: number; out: string }>;
132
+
133
+ async function spawnRun(argv: readonly string[]): Promise<{ code: number; out: string }> {
134
+ const child = Bun.spawn([...argv], { stdin: "ignore", stdout: "pipe", stderr: "ignore" });
135
+ const out = await new Response(child.stdout).text();
136
+ return { code: await child.exited, out };
137
+ }
138
+
139
+ function systemReadlink(path: string): string | null {
140
+ try {
141
+ return readlinkSync(path);
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ /** Real process access: ps and launchctl as argument arrays (no shell), lstat for temp folders, SIGTERM only. */
148
+ export function systemOrphanDeps(options: {
149
+ home: string;
150
+ kind: InstallKind;
151
+ tmp?: string;
152
+ user?: string;
153
+ run?: Run;
154
+ readlink?: (path: string) => string | null;
155
+ selfPid?: number;
156
+ }): OrphanDeps {
157
+ const run = options.run ?? spawnRun;
158
+ const readlink = options.readlink ?? systemReadlink;
159
+ const omo = join(options.home, "omo");
160
+ const processes = async (): Promise<OrphanProcess[] | null> => {
161
+ try {
162
+ const ps = await run(["ps", "-axww", "-o", "pid=,ppid=,user=,args="]);
163
+ return ps.code === 0 ? parseProcesses(ps.out) : null;
164
+ } catch {
165
+ return null;
166
+ }
167
+ };
168
+ return {
169
+ kind: options.kind,
170
+ tmp: options.tmp ?? process.env.TMPDIR ?? tmpdir(),
171
+ user: options.user ?? userInfo().username,
172
+ processes,
173
+ exists: (path) => {
174
+ try {
175
+ lstatSync(path);
176
+ return true;
177
+ } catch (error) {
178
+ return (error as NodeJS.ErrnoException).code === "ENOENT" ? false : null;
179
+ }
180
+ },
181
+ protectedPids: async () => {
182
+ const pids = [options.selfPid ?? process.pid];
183
+ try {
184
+ for (const label of LAUNCHD_LABELS) {
185
+ // launchctl exits non-zero when the job is not loaded: then it has no pid to protect.
186
+ const listed = await run(["launchctl", "list", label]);
187
+ const pid = listed.code === 0 ? parseLaunchctlPid(listed.out) : null;
188
+ if (pid !== null) pids.push(pid);
189
+ }
190
+ } catch {
191
+ return null;
192
+ }
193
+ const all = await processes();
194
+ if (all === null) return null;
195
+ const markers = [`${join(omo, "current")}${sep}`];
196
+ const target = readlink(join(omo, "current"));
197
+ if (target !== null) markers.push(`${isAbsolute(target) ? target : resolve(omo, target)}${sep}`);
198
+ for (const p of all) if (markers.some((marker) => p.args.includes(marker))) pids.push(p.pid);
199
+ return pids;
200
+ },
201
+ terminate: (pid) => {
202
+ process.kill(pid, "SIGTERM");
203
+ },
204
+ };
205
+ }
@@ -0,0 +1,55 @@
1
+ import type { WorkStatus } from "../../store.ts";
2
+ import type { ReconcileConfirmReason, ReconcileReason } from "../reconcile/work.ts";
3
+ import type { Candidate, Collector } from "../types.ts";
4
+
5
+ /**
6
+ * One reconcile verdict the WorkRouter currently holds (src/work.ts): a work it closed from evidence, or an unfinished work
7
+ * whose confirm alert waits for main. Kept in the router's memory only; the work rows and alerts are the durable record.
8
+ */
9
+ export interface ReconcileItem {
10
+ readonly workId: string;
11
+ readonly title: string;
12
+ readonly kind: "close" | "confirm";
13
+ readonly reason: ReconcileReason | ReconcileConfirmReason;
14
+ readonly status: WorkStatus;
15
+ readonly commandId: string;
16
+ readonly commandSeq: number;
17
+ readonly evidence: Record<string, unknown>;
18
+ readonly at: string;
19
+ }
20
+
21
+ export interface ReconcileWorkDeps {
22
+ /** Read through the WorkRouter (in-process); never the database from another process. */
23
+ items(): readonly ReconcileItem[];
24
+ }
25
+
26
+ export function reconcileWorkId(item: Pick<ReconcileItem, "reason" | "commandId">): string {
27
+ return `reconcile:${item.reason}:${item.commandId}`;
28
+ }
29
+
30
+ /**
31
+ * Role A (상태 정리) in the housekeeping list. The engine never writes work rows: closure and alerts happen in the
32
+ * WorkRouter, and a confirm is answered by main (cancel or a new instruct). An operator pick therefore returns
33
+ * `confirm_through_main` from the engine (confirmation operator_via_main) and `act` changes nothing.
34
+ */
35
+ export const reconcileWork: Collector<ReconcileWorkDeps> = {
36
+ name: "reconcile-work",
37
+ role: "reconcile",
38
+ triggers: ["work_event", "start", "web_open"],
39
+ confirmation: "operator_via_main",
40
+ reversibility: "state_reopen",
41
+ async find(deps) {
42
+ return deps.items().map((item): Candidate => ({
43
+ id: reconcileWorkId(item), collector: "reconcile-work", target: item.workId, reason: item.reason, ageMs: null, bytes: null,
44
+ details: { kind: item.kind, workId: item.workId, title: item.title, status: item.status, commandSeq: item.commandSeq, at: item.at },
45
+ }));
46
+ },
47
+ async judge(candidate, deps) {
48
+ const item = deps.items().find((i) => reconcileWorkId(i) === candidate.id);
49
+ if (!item) return { kind: "keep", why: "no_longer_current" };
50
+ return { kind: "act", why: item.reason, evidence: item.evidence };
51
+ },
52
+ async act() {
53
+ return { ok: false, method: "state", error: "confirm_through_main" };
54
+ },
55
+ };
@@ -0,0 +1,39 @@
1
+ import { say, type Text } from "../../server-text.ts";
2
+ import { type GcCandidate, gcCollector, type GcDeps, type RpcUnit } from "./shared.ts";
3
+
4
+ /**
5
+ * Old-version RPC host daemon that `rpc-host-gc.ts list --json` judges "stop" (rule of bug 62). The collector is auto:
6
+ * engine.scan sends SIGTERM (never SIGKILL) as soon as the rule holds; the old src/gc.ts report() never signals.
7
+ */
8
+
9
+ const REASON_DAEMON: Text = { en: "RPC host daemon of an earlier version, no parent, no connected client", ko: "이전 버전 RPC 호스트 데몬, 부모 없음, 연결된 클라이언트 없음" };
10
+
11
+ export async function daemonCandidates(deps: GcDeps): Promise<GcCandidate[]> {
12
+ const units = await deps.rpcUnits();
13
+ if (units === null) return [];
14
+ return units.filter((unit) => unit.verdict === "stop" && unit.pids.length > 0).map((unit) => ({
15
+ id: `rpc-daemon:${unit.version}:${unit.pids[0]}`, kind: "rpc-daemon" as const, target: `${unit.version} pid ${unit.pids.join(",")}`,
16
+ reason: say(REASON_DAEMON), ageMs: unit.ageMs, bytes: unit.rssKb * 1024, pids: unit.pids,
17
+ }));
18
+ }
19
+
20
+ /** `rpc-host-gc.ts list --json` output; any malformed entry makes the whole list unreadable (null). */
21
+ export function parseRpcUnits(text: string): RpcUnit[] | null {
22
+ let body: unknown;
23
+ try {
24
+ body = JSON.parse(text);
25
+ } catch {
26
+ return null;
27
+ }
28
+ if (!Array.isArray(body)) return null;
29
+ const units: RpcUnit[] = [];
30
+ for (const item of body as Record<string, unknown>[]) {
31
+ const { version, verdict, pids, ageMs, rssKb } = item ?? {};
32
+ if (typeof version !== "string" || typeof verdict !== "string" || typeof ageMs !== "number" || typeof rssKb !== "number") return null;
33
+ if (!Array.isArray(pids) || !pids.every((pid) => Number.isInteger(pid) && pid > 1)) return null;
34
+ units.push({ version, verdict, pids, ageMs, rssKb });
35
+ }
36
+ return units;
37
+ }
38
+
39
+ export const rpcDaemon = gcCollector({ kind: "rpc-daemon", npmOnly: true, root: null, candidates: (_roots, deps) => daemonCandidates(deps) });