@aitne-sh/aitne 0.1.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 (249) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +464 -0
  3. package/agent-assets/agent-profiles/_safety.md +26 -0
  4. package/agent-assets/agent-profiles/conversational.md +33 -0
  5. package/agent-assets/agent-profiles/docs-qa.md +24 -0
  6. package/agent-assets/agent-profiles/observer.md +28 -0
  7. package/agent-assets/agent-profiles/profile-importer.md +63 -0
  8. package/agent-assets/agent-profiles/proxy.md +28 -0
  9. package/agent-assets/agent-profiles/routine.md +16 -0
  10. package/agent-assets/agent-profiles/task.md +18 -0
  11. package/agent-assets/docs/concepts/agent-day.md +88 -0
  12. package/agent-assets/docs/concepts/auth-health.md +75 -0
  13. package/agent-assets/docs/concepts/backends-and-tiers.md +126 -0
  14. package/agent-assets/docs/concepts/costs-and-quotas.md +103 -0
  15. package/agent-assets/docs/concepts/delegated-mode.md +223 -0
  16. package/agent-assets/docs/concepts/memory-model.md +118 -0
  17. package/agent-assets/docs/concepts/observations.md +80 -0
  18. package/agent-assets/docs/concepts/process-keys.md +89 -0
  19. package/agent-assets/docs/concepts/routines.md +108 -0
  20. package/agent-assets/docs/concepts/safety-and-execution.md +109 -0
  21. package/agent-assets/docs/concepts/safety-model.md +279 -0
  22. package/agent-assets/docs/concepts/skills.md +100 -0
  23. package/agent-assets/docs/features/integrations/calendar.md +92 -0
  24. package/agent-assets/docs/features/integrations/git.md +95 -0
  25. package/agent-assets/docs/features/integrations/github.md +170 -0
  26. package/agent-assets/docs/features/integrations/mail.md +106 -0
  27. package/agent-assets/docs/features/integrations/notion.md +69 -0
  28. package/agent-assets/docs/features/integrations/obsidian.md +71 -0
  29. package/agent-assets/docs/features/lifestyle/git.md +178 -0
  30. package/agent-assets/docs/features/lifestyle/reading.md +93 -0
  31. package/agent-assets/docs/features/lifestyle/receipts.md +71 -0
  32. package/agent-assets/docs/features/lifestyle/travel-bookings.md +44 -0
  33. package/agent-assets/docs/features/lifestyle/travel-time.md +52 -0
  34. package/agent-assets/docs/features/memory-files/agent-journal.md +105 -0
  35. package/agent-assets/docs/features/memory-files/projects.md +56 -0
  36. package/agent-assets/docs/features/memory-files/roadmap.md +61 -0
  37. package/agent-assets/docs/features/memory-files/schedule.md +112 -0
  38. package/agent-assets/docs/features/memory-files/today.md +73 -0
  39. package/agent-assets/docs/features/memory-files/user-profile.md +81 -0
  40. package/agent-assets/docs/features/messaging/dashboard-chat.md +93 -0
  41. package/agent-assets/docs/features/messaging/discord.md +50 -0
  42. package/agent-assets/docs/features/messaging/overview.md +111 -0
  43. package/agent-assets/docs/features/messaging/pairing-and-magic-phrase.md +69 -0
  44. package/agent-assets/docs/features/messaging/slack.md +51 -0
  45. package/agent-assets/docs/features/messaging/telegram.md +63 -0
  46. package/agent-assets/docs/features/messaging/whatsapp.md +48 -0
  47. package/agent-assets/docs/features/operations/activity-and-conversations.md +105 -0
  48. package/agent-assets/docs/features/operations/approvals.md +58 -0
  49. package/agent-assets/docs/features/operations/backend-routing.md +62 -0
  50. package/agent-assets/docs/features/operations/cost-tracking.md +59 -0
  51. package/agent-assets/docs/features/operations/notifications.md +69 -0
  52. package/agent-assets/docs/features/operations/quiet-hours.md +106 -0
  53. package/agent-assets/docs/features/operations/schedule-approaching.md +60 -0
  54. package/agent-assets/docs/features/routines/custom-routines.md +101 -0
  55. package/agent-assets/docs/features/routines/evening-review.md +81 -0
  56. package/agent-assets/docs/features/routines/hourly-check.md +85 -0
  57. package/agent-assets/docs/features/routines/monthly-review.md +65 -0
  58. package/agent-assets/docs/features/routines/morning-routine.md +123 -0
  59. package/agent-assets/docs/features/routines/weekly-review.md +70 -0
  60. package/agent-assets/docs/getting-started/01-what-is-this.md +192 -0
  61. package/agent-assets/docs/getting-started/02-first-steps.md +80 -0
  62. package/agent-assets/docs/getting-started/03-what-can-this-do.md +110 -0
  63. package/agent-assets/docs/getting-started/04-first-day.md +287 -0
  64. package/agent-assets/docs/glossary.md +116 -0
  65. package/agent-assets/docs/guides/add-a-custom-routine.md +71 -0
  66. package/agent-assets/docs/guides/backup-and-restore.md +54 -0
  67. package/agent-assets/docs/guides/change-which-model-handles-x.md +47 -0
  68. package/agent-assets/docs/guides/connect-a-new-mail-account.md +59 -0
  69. package/agent-assets/docs/guides/import-knowledge-file.md +275 -0
  70. package/agent-assets/docs/guides/install-and-run.md +72 -0
  71. package/agent-assets/docs/guides/migrate-machines.md +52 -0
  72. package/agent-assets/docs/guides/pause-the-agent.md +65 -0
  73. package/agent-assets/docs/guides/reinstall-cleanly.md +52 -0
  74. package/agent-assets/docs/guides/setup-wizard.md +107 -0
  75. package/agent-assets/docs/guides/switch-default-backend.md +60 -0
  76. package/agent-assets/docs/reference/api.md +51 -0
  77. package/agent-assets/docs/reference/cli-commands.md +121 -0
  78. package/agent-assets/docs/reference/config.md +74 -0
  79. package/agent-assets/docs/reference/disallowed-tools.md +76 -0
  80. package/agent-assets/docs/reference/keyboard-shortcuts.md +39 -0
  81. package/agent-assets/docs/reference/process-keys.md +59 -0
  82. package/agent-assets/docs/reference/skills.md +50 -0
  83. package/agent-assets/docs/troubleshooting/auth-failed.md +57 -0
  84. package/agent-assets/docs/troubleshooting/dashboard-shows-degraded.md +55 -0
  85. package/agent-assets/docs/troubleshooting/fallback-keeps-firing.md +54 -0
  86. package/agent-assets/docs/troubleshooting/messaging-not-pairing.md +53 -0
  87. package/agent-assets/docs/troubleshooting/morning-routine-didnt-run.md +75 -0
  88. package/agent-assets/docs/troubleshooting/observation-not-detected.md +57 -0
  89. package/agent-assets/docs/troubleshooting/quota-exhausted.md +57 -0
  90. package/agent-assets/optimizer-skills/drift-analysis/SKILL.md +75 -0
  91. package/agent-assets/optimizer-skills/knowledge-map/SKILL.md +71 -0
  92. package/agent-assets/optimizer-skills/skill-curation/SKILL.md +108 -0
  93. package/agent-assets/project-doc-templates/git-repo.md +21 -0
  94. package/agent-assets/project-doc-templates/project.md +38 -0
  95. package/agent-assets/skills/attach/SKILL.md +104 -0
  96. package/agent-assets/skills/context/SKILL.md +257 -0
  97. package/agent-assets/skills/context/curation.json +37 -0
  98. package/agent-assets/skills/context/seeds/file-responsibilities.seed.json +13 -0
  99. package/agent-assets/skills/context/seeds/frontmatter-requirements.seed.json +40 -0
  100. package/agent-assets/skills/docs-search/SKILL.md +176 -0
  101. package/agent-assets/skills/external-services/SKILL.delegated.claude.md +369 -0
  102. package/agent-assets/skills/external-services/SKILL.delegated.codex.md +349 -0
  103. package/agent-assets/skills/external-services/SKILL.delegated.gemini.md +347 -0
  104. package/agent-assets/skills/external-services/SKILL.md +371 -0
  105. package/agent-assets/skills/mail/SKILL.delegated.claude.md +284 -0
  106. package/agent-assets/skills/mail/SKILL.delegated.codex.md +261 -0
  107. package/agent-assets/skills/mail/SKILL.delegated.gemini.md +255 -0
  108. package/agent-assets/skills/mail/SKILL.md +313 -0
  109. package/agent-assets/skills/mail/references/errors.md +17 -0
  110. package/agent-assets/skills/mail/references/providers.md +40 -0
  111. package/agent-assets/skills/mail/references/query-grammar.md +24 -0
  112. package/agent-assets/skills/management-policy/SKILL.md +307 -0
  113. package/agent-assets/skills/management-policy/curation.json +13 -0
  114. package/agent-assets/skills/management-policy/seeds/policy-file-shape.seed.json +16 -0
  115. package/agent-assets/skills/management-task-modify/SKILL.md +202 -0
  116. package/agent-assets/skills/management-task-register/SKILL.md +330 -0
  117. package/agent-assets/skills/management-task-stop/SKILL.md +166 -0
  118. package/agent-assets/skills/notify/SKILL.md +196 -0
  119. package/agent-assets/skills/notion/SKILL.delegated.claude.md +254 -0
  120. package/agent-assets/skills/notion/SKILL.delegated.codex.md +195 -0
  121. package/agent-assets/skills/notion/SKILL.delegated.gemini.md +194 -0
  122. package/agent-assets/skills/notion/SKILL.md +86 -0
  123. package/agent-assets/skills/observations/SKILL.md +234 -0
  124. package/agent-assets/skills/observations/curation.json +13 -0
  125. package/agent-assets/skills/observations/seeds/source-namespacing.seed.json +20 -0
  126. package/agent-assets/skills/project-doc/SKILL.md +86 -0
  127. package/agent-assets/skills/project-doc/curation.json +21 -0
  128. package/agent-assets/skills/project-doc/seeds/project-shape.seed.json +25 -0
  129. package/agent-assets/skills/project-doc/seeds/slug-grammar.seed.json +20 -0
  130. package/agent-assets/skills/reading/SKILL.md +198 -0
  131. package/agent-assets/skills/reading/references/reading-taste.md +197 -0
  132. package/agent-assets/skills/receipts/SKILL.md +134 -0
  133. package/agent-assets/skills/roadmap/SKILL.md +276 -0
  134. package/agent-assets/skills/roadmap/curation.json +13 -0
  135. package/agent-assets/skills/roadmap/references/horizon-tags.md +40 -0
  136. package/agent-assets/skills/roadmap/references/preparation-timeline.md +47 -0
  137. package/agent-assets/skills/roadmap/seeds/entry-types.seed.json +16 -0
  138. package/agent-assets/skills/schedule/SKILL.md +228 -0
  139. package/agent-assets/skills/scheduled-managed-task/SKILL.md +392 -0
  140. package/agent-assets/skills/today/SKILL.md +198 -0
  141. package/agent-assets/skills/today/curation.json +21 -0
  142. package/agent-assets/skills/today/seeds/agent-notes-flavors.seed.json +17 -0
  143. package/agent-assets/skills/today/seeds/section-shape.seed.json +17 -0
  144. package/agent-assets/skills/travel/SKILL.md +132 -0
  145. package/agent-assets/skills/travel-time/SKILL.md +149 -0
  146. package/agent-assets/skills/user-interview/SKILL.md +323 -0
  147. package/agent-assets/skills/user-interview/references/sweep-and-fallback.md +94 -0
  148. package/agent-assets/skills/user-profile/SKILL.md +210 -0
  149. package/agent-assets/skills/user-profile/curation.json +29 -0
  150. package/agent-assets/skills/user-profile/seeds/learned-context-format.seed.json +14 -0
  151. package/agent-assets/skills/user-profile/seeds/routing-table.seed.json +53 -0
  152. package/agent-assets/skills/user-profile/seeds/topic-files.seed.json +27 -0
  153. package/agent-assets/task-flows/dashboard.docs_qa.md +43 -0
  154. package/agent-assets/task-flows/default.md +11 -0
  155. package/agent-assets/task-flows/git.branch.created.md +25 -0
  156. package/agent-assets/task-flows/git.lifecycle.poll.md +52 -0
  157. package/agent-assets/task-flows/git.local_ahead.stale.md +34 -0
  158. package/agent-assets/task-flows/git.merge_to_default.md +30 -0
  159. package/agent-assets/task-flows/git.project.refresh_architecture.md +100 -0
  160. package/agent-assets/task-flows/git.project.retemplate.md +73 -0
  161. package/agent-assets/task-flows/git.push.detected.md +32 -0
  162. package/agent-assets/task-flows/git.push.force_pushed.md +36 -0
  163. package/agent-assets/task-flows/git.tag.created.md +24 -0
  164. package/agent-assets/task-flows/github.assigned.md +43 -0
  165. package/agent-assets/task-flows/github.pull_request.review_requested.md +57 -0
  166. package/agent-assets/task-flows/github.security_alert.md +45 -0
  167. package/agent-assets/task-flows/github.workflow_run.failed.md +57 -0
  168. package/agent-assets/task-flows/knowledge.import.md +161 -0
  169. package/agent-assets/task-flows/message.received.dm.md +142 -0
  170. package/agent-assets/task-flows/message.received.dm_first.md +117 -0
  171. package/agent-assets/task-flows/message.received.md +14 -0
  172. package/agent-assets/task-flows/routine.custom.md +38 -0
  173. package/agent-assets/task-flows/routine.evening_review.md +323 -0
  174. package/agent-assets/task-flows/routine.hourly_check.delegated.claude.md +405 -0
  175. package/agent-assets/task-flows/routine.hourly_check.delegated.codex.md +400 -0
  176. package/agent-assets/task-flows/routine.hourly_check.delegated.gemini.md +404 -0
  177. package/agent-assets/task-flows/routine.hourly_check.md +184 -0
  178. package/agent-assets/task-flows/routine.hourly_check.triage.md +93 -0
  179. package/agent-assets/task-flows/routine.monthly_review.md +250 -0
  180. package/agent-assets/task-flows/routine.morning_routine.md +300 -0
  181. package/agent-assets/task-flows/routine.morning_routine_initial.md +184 -0
  182. package/agent-assets/task-flows/routine.roadmap_refresh.md +275 -0
  183. package/agent-assets/task-flows/routine.today_refresh.md +172 -0
  184. package/agent-assets/task-flows/routine.user_profile_sweep.md +242 -0
  185. package/agent-assets/task-flows/routine.weekly_review.md +247 -0
  186. package/agent-assets/task-flows/schedule.approaching.md +124 -0
  187. package/agent-assets/task-flows/scheduled.dm.md +391 -0
  188. package/agent-assets/task-flows/scheduled.task.md +141 -0
  189. package/agent-assets/task-flows/setup.initial.md +277 -0
  190. package/agent-assets/task-flows/setup.update.md +53 -0
  191. package/agent-assets/templates/README.md +85 -0
  192. package/agent-assets/templates/_index.md +39 -0
  193. package/agent-assets/templates/_manifest.json +103 -0
  194. package/agent-assets/templates/agent/journal.md +10 -0
  195. package/agent-assets/templates/agent/profile-questions.md +74 -0
  196. package/agent-assets/templates/context-index.md +42 -0
  197. package/agent-assets/templates/dossiers/_index.md +22 -0
  198. package/agent-assets/templates/dossiers/evening.md +23 -0
  199. package/agent-assets/templates/dossiers/hourly.md +23 -0
  200. package/agent-assets/templates/dossiers/monthly.md +23 -0
  201. package/agent-assets/templates/dossiers/morning.md +23 -0
  202. package/agent-assets/templates/dossiers/roadmap.md +23 -0
  203. package/agent-assets/templates/dossiers/weekly.md +23 -0
  204. package/agent-assets/templates/projects/_active.base +14 -0
  205. package/agent-assets/templates/projects/_index.md +29 -0
  206. package/agent-assets/templates/roadmap.md +15 -0
  207. package/agent-assets/templates/routines/_index.md +20 -0
  208. package/agent-assets/templates/routines/evening.md +22 -0
  209. package/agent-assets/templates/routines/hourly.md +30 -0
  210. package/agent-assets/templates/routines/monthly.md +25 -0
  211. package/agent-assets/templates/routines/morning.md +26 -0
  212. package/agent-assets/templates/routines/weekly.md +23 -0
  213. package/agent-assets/templates/rules/_index.md +19 -0
  214. package/agent-assets/templates/rules/journal-export.md +41 -0
  215. package/agent-assets/templates/rules/journal-format.md +61 -0
  216. package/agent-assets/templates/rules/management.md +48 -0
  217. package/agent-assets/templates/rules/mcp.md +40 -0
  218. package/agent-assets/templates/rules/policies/_index.md +22 -0
  219. package/agent-assets/templates/rules/redaction.md +30 -0
  220. package/agent-assets/templates/today.md +13 -0
  221. package/agent-assets/templates/user/_index.md +16 -0
  222. package/agent-assets/templates/user/expertise.md +7 -0
  223. package/agent-assets/templates/user/goals.md +7 -0
  224. package/agent-assets/templates/user/people.md +7 -0
  225. package/agent-assets/templates/user/personal.md +7 -0
  226. package/agent-assets/templates/user/profile.md +28 -0
  227. package/agent-assets/templates/user/work.md +7 -0
  228. package/bin/aitne.mjs +1096 -0
  229. package/package.json +78 -0
  230. package/personal-agent.mjs +39 -0
  231. package/scripts/browser.mjs +99 -0
  232. package/scripts/check-redaction-coverage.mjs +109 -0
  233. package/scripts/commands/audit.mjs +309 -0
  234. package/scripts/commands/doctor.mjs +437 -0
  235. package/scripts/commands/open.mjs +40 -0
  236. package/scripts/commands/setup.mjs +21 -0
  237. package/scripts/commands/uninstall.mjs +114 -0
  238. package/scripts/commands/update.mjs +96 -0
  239. package/scripts/commands/version.mjs +62 -0
  240. package/scripts/commands.md +0 -0
  241. package/scripts/lib/sqlite-loader.mjs +49 -0
  242. package/scripts/message-discipline-digest.mjs +535 -0
  243. package/scripts/poc/google-connector-inheritance/REPORT.md +197 -0
  244. package/scripts/poc/google-connector-inheritance/claude-sdk-probe.mjs +79 -0
  245. package/scripts/remint-roadmap-ids.mjs +257 -0
  246. package/scripts/rm-paths.mjs +22 -0
  247. package/scripts/run-node.mjs +223 -0
  248. package/scripts/smoke-obsidian-api.mjs +166 -0
  249. package/scripts/start.mjs +160 -0
package/bin/aitne.mjs ADDED
@@ -0,0 +1,1096 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync, spawn } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import { createRequire } from "node:module";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+ import process from "node:process";
8
+ import { fileURLToPath } from "node:url";
9
+ import { ensureBuild } from "../scripts/run-node.mjs";
10
+ import { fetchHttpOk, openBrowser } from "../scripts/browser.mjs";
11
+
12
+ const IS_WINDOWS = process.platform === "win32";
13
+
14
+ /**
15
+ * aitne — CLI for managing the Aitne local-first personal agent.
16
+ *
17
+ * Lifecycle:
18
+ * aitne start [--no-open] Build & launch daemon + dashboard
19
+ * aitne stop Graceful shutdown
20
+ * aitne restart [--clean-context] Stop then start
21
+ * aitne status PIDs, uptime, integrations, today spend
22
+ * aitne logs [-f] [-n N] [-d] View daemon (or dashboard) logs
23
+ * aitne dev Foreground mode (development)
24
+ * aitne build Explicit build
25
+ *
26
+ * Operations:
27
+ * aitne setup Open dashboard /setup wizard
28
+ * aitne open Open dashboard root
29
+ * aitne doctor Diagnose install issues
30
+ * aitne audit [--since 24h] [--type X] Show agent action log
31
+ * aitne version Print version + environment
32
+ * aitne update Print npm upgrade command
33
+ * aitne uninstall Stop, then offer to wipe data dir
34
+ * aitne help [cmd] Show help (or per-command help)
35
+ *
36
+ * APP_NAME is hardcoded inline here, not imported from packages/shared, because
37
+ * this bin runs *before* `pnpm build` completes (the whole point of `aitne
38
+ * start` is to trigger that build), so importing from `shared/dist/` would fail
39
+ * on a fresh checkout. Keep this in sync with packages/shared/src/branding.ts.
40
+ */
41
+ const APP_NAME = "Aitne";
42
+ // MUST stay in sync with packages/shared/src/branding.ts:APP_NAME
43
+
44
+ // ── Resolve project root (works from bin symlink, direct invocation, or
45
+ // node_modules/aitne/bin/ once published — `..` resolves to package root in
46
+ // every case). ──
47
+ const BIN_FILE = fileURLToPath(import.meta.url);
48
+ const __dirname = path.dirname(BIN_FILE);
49
+ const PROJECT_ROOT = path.resolve(__dirname, "..");
50
+ const requireFromBin = createRequire(import.meta.url);
51
+
52
+ const DATA_DIR = process.env.PA_DATA_DIR || path.join(os.homedir(), ".personal-agent");
53
+ const PIDS_DIR = path.join(DATA_DIR, "run");
54
+ const DAEMON_PID_FILE = path.join(PIDS_DIR, "daemon.pid");
55
+ const DASHBOARD_PID_FILE = path.join(PIDS_DIR, "dashboard.pid");
56
+ const DAEMON_LOG_FILE = path.join(DATA_DIR, "logs", "daemon.log");
57
+ const DASHBOARD_LOG_FILE = path.join(DATA_DIR, "logs", "dashboard.log");
58
+ const DAEMON_PORT = parseInt(process.env.PA_API_PORT || "8321", 10);
59
+ const DASHBOARD_PORT = parseInt(process.env.PA_DASHBOARD_PORT || "3000", 10);
60
+
61
+ const VERSION = JSON.parse(
62
+ fs.readFileSync(path.join(PROJECT_ROOT, "package.json"), "utf8"),
63
+ ).version || "0.1.0";
64
+
65
+ // ── PID helpers ──
66
+
67
+ const parsedLogMaxBytes = parseInt(process.env.PA_LOG_MAX_BYTES || "", 10);
68
+ const LOG_MAX_BYTES =
69
+ Number.isFinite(parsedLogMaxBytes) && parsedLogMaxBytes > 0
70
+ ? parsedLogMaxBytes
71
+ : 10 * 1024 * 1024;
72
+
73
+ function ensureDirs() {
74
+ fs.mkdirSync(PIDS_DIR, { recursive: true });
75
+ fs.mkdirSync(path.dirname(DAEMON_LOG_FILE), { recursive: true });
76
+ }
77
+
78
+ /** Rotate log if over threshold. Keeps one .1 backup. */
79
+ function rotateLogIfNeeded(logFile, maxBytes = LOG_MAX_BYTES) {
80
+ try {
81
+ const stat = fs.statSync(logFile);
82
+ if (stat.size > maxBytes) rotateLogFile(logFile);
83
+ } catch { /* file doesn't exist yet — fine */ }
84
+ }
85
+
86
+ function rotateLogFile(logFile) {
87
+ const rotated = logFile + ".1";
88
+ try { fs.unlinkSync(rotated); } catch { /* ignore */ }
89
+ try { fs.renameSync(logFile, rotated); } catch { /* ignore */ }
90
+ }
91
+
92
+ function readPid(pidFile) {
93
+ try {
94
+ const content = fs.readFileSync(pidFile, "utf8").trim();
95
+ const pid = parseInt(content, 10);
96
+ return Number.isFinite(pid) ? pid : null;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ function writePid(pidFile, pid) {
103
+ fs.writeFileSync(pidFile, String(pid) + "\n");
104
+ }
105
+
106
+ function removePid(pidFile) {
107
+ try { fs.unlinkSync(pidFile); } catch { /* ignore */ }
108
+ }
109
+
110
+ function isAlive(pid) {
111
+ try { process.kill(pid, 0); return true; } catch { return false; }
112
+ }
113
+
114
+ /**
115
+ * Resolve the dashboard package directory.
116
+ *
117
+ * Workspace dev: pnpm symlinks node_modules/@aitne/dashboard → packages/dashboard
118
+ * Published install: @aitne/dashboard is installed as a sibling node_modules entry
119
+ *
120
+ * Falls back to PROJECT_ROOT/packages/dashboard for fresh checkouts before
121
+ * `pnpm install` has created the symlink.
122
+ */
123
+ function resolveDashboardDir() {
124
+ try {
125
+ return path.dirname(requireFromBin.resolve("@aitne/dashboard/package.json"));
126
+ } catch {
127
+ return path.join(PROJECT_ROOT, "packages/dashboard");
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Resolve the dashboard `next` binary for the current OS.
133
+ *
134
+ * Prefer Node's package resolution for Next's real CLI entrypoint. This works
135
+ * in the pnpm workspace, in npm global installs, and in local npm installs
136
+ * where dependencies are hoisted to the parent app's node_modules instead of
137
+ * living under node_modules/aitne/node_modules.
138
+ *
139
+ * The fallback .bin lookup is kept for unusual layouts and older installs.
140
+ * Batch shims need `cmd.exe` on Windows, so the resolved Node entrypoint is
141
+ * preferred there too.
142
+ */
143
+ function resolveNextBin(dashboardDir) {
144
+ try {
145
+ requireFromBin.resolve("next/dist/bin/next");
146
+ return process.execPath;
147
+ } catch {
148
+ // Fall through to legacy .bin probing.
149
+ }
150
+ const binDir = path.join(dashboardDir, "node_modules", ".bin");
151
+ if (IS_WINDOWS) {
152
+ const direct = path.join(dashboardDir, "node_modules", "next", "dist", "bin", "next");
153
+ if (fs.existsSync(direct)) return process.execPath;
154
+ const cmd = path.join(binDir, "next.cmd");
155
+ if (fs.existsSync(cmd)) return cmd;
156
+ }
157
+ return path.join(binDir, "next");
158
+ }
159
+
160
+ /**
161
+ * Build the spawn args for the dashboard. When resolveNextBin returns the
162
+ * current Node executable, inject Next's CLI entrypoint before the user's args.
163
+ * Mirrors the candidate-dirs fallback lookup in resolveNextBin.
164
+ */
165
+ function nextSpawnArgs(dashboardDir, nextBin, userArgs) {
166
+ if (nextBin === process.execPath) {
167
+ try {
168
+ return [requireFromBin.resolve("next/dist/bin/next"), ...userArgs];
169
+ } catch {
170
+ // Fall through to direct-path probing.
171
+ }
172
+ for (const dir of [dashboardDir, PROJECT_ROOT]) {
173
+ const direct = path.join(dir, "node_modules", "next", "dist", "bin", "next");
174
+ if (fs.existsSync(direct)) return [direct, ...userArgs];
175
+ }
176
+ }
177
+ return userArgs;
178
+ }
179
+
180
+ function getRunningPid(pidFile) {
181
+ const pid = readPid(pidFile);
182
+ if (pid == null) return null;
183
+ if (!isAlive(pid)) { removePid(pidFile); return null; }
184
+ return pid;
185
+ }
186
+
187
+ /**
188
+ * Kill `pid` (and its descendants where supported) and wait for it to exit.
189
+ *
190
+ * - POSIX: signal the process *group* via `process.kill(-pid, ...)` so any
191
+ * child processes the daemon spawned (Claude/Codex/Gemini CLIs, the
192
+ * bundled Next dashboard) also receive the signal. Falls back to a
193
+ * single-process kill if the group call fails.
194
+ * - Windows: no process groups; use `taskkill /T /F /PID <pid>` to walk the
195
+ * parent-pid chain and terminate descendants. `/F` is required because
196
+ * Windows console apps don't honor a graceful close — Node simulates
197
+ * SIGTERM as TerminateProcess in this scenario, so a graceful first pass
198
+ * would no-op.
199
+ */
200
+ function killTree(pid, signal) {
201
+ if (IS_WINDOWS) {
202
+ try {
203
+ execFileSync("taskkill", ["/T", "/F", "/PID", String(pid)], {
204
+ stdio: "pipe",
205
+ windowsHide: true,
206
+ });
207
+ return true;
208
+ } catch {
209
+ // Fall through to per-process kill below.
210
+ }
211
+ try { process.kill(pid, signal); return true; } catch { return false; }
212
+ }
213
+ try { process.kill(-pid, signal); return true; } catch {
214
+ try { process.kill(pid, signal); return true; } catch { return false; }
215
+ }
216
+ }
217
+
218
+ async function killAndWait(pid, pidFile, label, timeoutMs = 10_000) {
219
+ killTree(pid, "SIGTERM");
220
+ const deadline = Date.now() + timeoutMs;
221
+ while (Date.now() < deadline) {
222
+ if (!isAlive(pid)) { removePid(pidFile); return true; }
223
+ await new Promise((r) => setTimeout(r, 150));
224
+ }
225
+ // Force kill if still alive. On Windows the first taskkill /F already
226
+ // hard-kills, so this branch is mostly a POSIX escalation.
227
+ killTree(pid, "SIGKILL");
228
+ await new Promise((r) => setTimeout(r, 300));
229
+ if (!isAlive(pid)) { removePid(pidFile); return true; }
230
+ return false;
231
+ }
232
+
233
+ /**
234
+ * Print the last N lines of a log file to stderr, framed so it's visible.
235
+ * Used on startup failure so the user gets immediate signal instead of
236
+ * having to chase `pa logs`. Falls back silently if the file is missing.
237
+ */
238
+ function printLogTail(logFile, n) {
239
+ try {
240
+ const content = fs.readFileSync(logFile, "utf8");
241
+ const lines = content.split("\n").filter((l) => l.length > 0);
242
+ const tail = lines.slice(-n);
243
+ if (tail.length === 0) return;
244
+ console.log("");
245
+ console.log(` --- last ${tail.length} log line(s) ---`);
246
+ for (const line of tail) console.log(` ${line}`);
247
+ console.log(` --- end of log ---`);
248
+ } catch {
249
+ /* log not present — caller already told the user where to look */
250
+ }
251
+ }
252
+
253
+ function encodeLogRunnerSpec(spec) {
254
+ return Buffer.from(JSON.stringify(spec), "utf8").toString("base64url");
255
+ }
256
+
257
+ function decodeLogRunnerSpec(encoded) {
258
+ return JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
259
+ }
260
+
261
+ function spawnLoggedService({ command, args, cwd, logFile, env, shell = false }) {
262
+ return spawn(
263
+ process.execPath,
264
+ [BIN_FILE, "_log-runner", encodeLogRunnerSpec({ command, args, cwd, logFile, shell })],
265
+ {
266
+ cwd: PROJECT_ROOT,
267
+ detached: true,
268
+ stdio: "ignore",
269
+ env,
270
+ windowsHide: true,
271
+ },
272
+ );
273
+ }
274
+
275
+ async function cmdLogRunner(args) {
276
+ const spec = decodeLogRunnerSpec(args[0] || "");
277
+ fs.mkdirSync(path.dirname(spec.logFile), { recursive: true });
278
+ rotateLogIfNeeded(spec.logFile);
279
+
280
+ let fd = fs.openSync(spec.logFile, "a");
281
+ let size = 0;
282
+ try {
283
+ size = fs.fstatSync(fd).size;
284
+ } catch {
285
+ size = 0;
286
+ }
287
+
288
+ const reopen = () => {
289
+ try { fs.closeSync(fd); } catch { /* ignore */ }
290
+ rotateLogFile(spec.logFile);
291
+ fd = fs.openSync(spec.logFile, "a");
292
+ size = 0;
293
+ };
294
+
295
+ const writeChunk = (chunk) => {
296
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
297
+ let offset = 0;
298
+ while (offset < buf.length) {
299
+ if (size >= LOG_MAX_BYTES) reopen();
300
+ const room = Math.max(1, LOG_MAX_BYTES - size);
301
+ const length = Math.min(room, buf.length - offset);
302
+ fs.writeSync(fd, buf, offset, length);
303
+ offset += length;
304
+ size += length;
305
+ }
306
+ };
307
+
308
+ const child = spawn(spec.command, spec.args, {
309
+ cwd: spec.cwd,
310
+ env: process.env,
311
+ stdio: ["ignore", "pipe", "pipe"],
312
+ windowsHide: true,
313
+ shell: spec.shell === true,
314
+ });
315
+
316
+ child.stdout.on("data", writeChunk);
317
+ child.stderr.on("data", writeChunk);
318
+ child.on("error", (err) => {
319
+ writeChunk(`[log-runner] failed to start child: ${err.message}\n`);
320
+ try { fs.closeSync(fd); } catch { /* ignore */ }
321
+ process.exit(1);
322
+ });
323
+
324
+ const stopChild = (signal) => {
325
+ try { child.kill(signal); } catch { /* ignore */ }
326
+ };
327
+ process.on("SIGTERM", () => stopChild("SIGTERM"));
328
+ process.on("SIGINT", () => stopChild("SIGINT"));
329
+
330
+ child.on("exit", (code, signal) => {
331
+ try { fs.closeSync(fd); } catch { /* ignore */ }
332
+ if (signal) process.exit(128);
333
+ process.exit(code ?? 0);
334
+ });
335
+ }
336
+
337
+ async function fetchHealth() {
338
+ try {
339
+ const controller = new AbortController();
340
+ const timeout = setTimeout(() => controller.abort(), 3000);
341
+ const res = await fetch(`http://127.0.0.1:${DAEMON_PORT}/api/health`, {
342
+ signal: controller.signal,
343
+ });
344
+ clearTimeout(timeout);
345
+ if (res.ok) return await res.json();
346
+ } catch { /* not responding */ }
347
+ return null;
348
+ }
349
+
350
+ function formatUptime(totalSeconds) {
351
+ const s = Math.floor(totalSeconds);
352
+ const m = Math.floor(s / 60);
353
+ const h = Math.floor(m / 60);
354
+ const d = Math.floor(h / 24);
355
+ if (d > 0) return `${d}d ${h % 24}h ${m % 60}m`;
356
+ if (h > 0) return `${h}h ${m % 60}m`;
357
+ if (m > 0) return `${m}m ${s % 60}s`;
358
+ return `${s}s`;
359
+ }
360
+
361
+ // ── Commands ──
362
+
363
+ async function cmdStart(args = []) {
364
+ const noOpen = args.includes("--no-open");
365
+ const daemonPid = getRunningPid(DAEMON_PID_FILE);
366
+ const dashPid = getRunningPid(DASHBOARD_PID_FILE);
367
+ if (daemonPid && dashPid) {
368
+ console.log(`Already running (daemon: ${daemonPid}, dashboard: ${dashPid}).`);
369
+ if (!noOpen) {
370
+ const url = `http://localhost:${DASHBOARD_PORT}`;
371
+ if (await openBrowser(url)) console.log(` Opened ${url} in browser.`);
372
+ }
373
+ // `pa start` on an already-running instance is idempotent, not an error.
374
+ process.exit(0);
375
+ }
376
+ if (daemonPid || dashPid) {
377
+ // Partial state — stop first
378
+ console.log("Partial state detected, cleaning up...");
379
+ await cmdStop();
380
+ await new Promise((r) => setTimeout(r, 500));
381
+ }
382
+
383
+ // Build if needed (quiet — only show output on failure)
384
+ const { shouldBuild } = await import("../scripts/run-node.mjs");
385
+ if (shouldBuild(PROJECT_ROOT)) {
386
+ process.stdout.write("Building...");
387
+ const buildCode = await ensureBuild(PROJECT_ROOT, { quiet: true });
388
+ if (buildCode !== 0) {
389
+ console.log(" failed.");
390
+ process.exit(buildCode);
391
+ }
392
+ console.log(" done.");
393
+ }
394
+
395
+ ensureDirs();
396
+ rotateLogIfNeeded(DAEMON_LOG_FILE);
397
+ rotateLogIfNeeded(DASHBOARD_LOG_FILE);
398
+
399
+ // Start daemon under a tiny log runner. The runner owns the file handle and
400
+ // rotates while the service is running; direct child fd redirection can only
401
+ // rotate on the next `aitne start`.
402
+ const daemon = spawnLoggedService({
403
+ command: process.execPath,
404
+ args: ["personal-agent.mjs"],
405
+ cwd: PROJECT_ROOT,
406
+ logFile: DAEMON_LOG_FILE,
407
+ env: { ...process.env, PA_DAEMONIZED: "1" },
408
+ });
409
+ writePid(DAEMON_PID_FILE, daemon.pid);
410
+ daemon.unref();
411
+
412
+ // Start dashboard (next start — production mode).
413
+ // Resolve via package.json so we work in both workspace dev (pnpm symlinks
414
+ // node_modules/@aitne/dashboard → packages/dashboard) and global installs
415
+ // (where @aitne/dashboard is a sibling node_modules entry).
416
+ const dashboardDir = resolveDashboardDir();
417
+ // Prefer Next's direct Node entrypoint on Windows; `.cmd` shims require
418
+ // cmd.exe and are kept only as a last fallback.
419
+ const nextBin = resolveNextBin(dashboardDir);
420
+ const dashArgs = nextSpawnArgs(dashboardDir, nextBin, [
421
+ "start", "--port", String(DASHBOARD_PORT),
422
+ ]);
423
+ const dashboard = spawnLoggedService({
424
+ command: nextBin,
425
+ args: dashArgs,
426
+ cwd: dashboardDir,
427
+ logFile: DASHBOARD_LOG_FILE,
428
+ env: { ...process.env, PA_DAEMONIZED: "1" },
429
+ shell: IS_WINDOWS && nextBin.toLowerCase().endsWith(".cmd"),
430
+ });
431
+ writePid(DASHBOARD_PID_FILE, dashboard.pid);
432
+ dashboard.unref();
433
+
434
+ // Verify startup — wait for daemon health AND dashboard HTTP in one phase,
435
+ // so the "ok." line is only printed once everything is actually ready.
436
+ process.stdout.write("Starting...");
437
+ const status = await verifyStartup(daemon.pid, dashboard.pid);
438
+ if (!status.daemon) {
439
+ console.log(" failed.");
440
+ const daemonAlive = isAlive(daemon.pid);
441
+ if (daemonAlive) {
442
+ console.log(` Daemon process ${daemon.pid} is alive but /api/health did not respond — likely hung during startup.`);
443
+ } else {
444
+ console.log(` Daemon process ${daemon.pid} exited before becoming ready.`);
445
+ }
446
+ console.log(` Check logs: ${DAEMON_LOG_FILE}`);
447
+ printLogTail(DAEMON_LOG_FILE, 30);
448
+ // Leave the hung daemon cleaned up so the next `pa start` is not
449
+ // short-circuited by the "already running" check — otherwise the user
450
+ // would have to `pa stop` first.
451
+ if (daemonAlive) {
452
+ await killAndWait(daemon.pid, DAEMON_PID_FILE, "daemon", 5_000);
453
+ } else {
454
+ removePid(DAEMON_PID_FILE);
455
+ }
456
+ if (isAlive(dashboard.pid)) {
457
+ await killAndWait(dashboard.pid, DASHBOARD_PID_FILE, "dashboard", 5_000);
458
+ } else {
459
+ removePid(DASHBOARD_PID_FILE);
460
+ }
461
+ process.exit(1);
462
+ }
463
+ console.log(" ok.");
464
+
465
+ let dashSuffix = "";
466
+ if (!status.dashboard) {
467
+ dashSuffix = " (not yet responding — open manually)";
468
+ } else if (!noOpen) {
469
+ const url = `http://localhost:${DASHBOARD_PORT}`;
470
+ if (await openBrowser(url)) dashSuffix = " (opened in browser)";
471
+ }
472
+
473
+ console.log(` Daemon: PID ${daemon.pid} → http://127.0.0.1:${DAEMON_PORT}`);
474
+ console.log(` Dashboard: PID ${dashboard.pid} → http://localhost:${DASHBOARD_PORT}${dashSuffix}`);
475
+
476
+ // Post-startup summary — best-effort. /api/health is the daemon's own
477
+ // synthesis of ready integrations + auth state, so we lean on it instead
478
+ // of re-querying multiple endpoints. Skip silently if anything is shaped
479
+ // unexpectedly — a successful daemon start is the signal here, not this
480
+ // line.
481
+ try {
482
+ const finalHealth = await fetchHealth();
483
+ if (finalHealth) {
484
+ const platforms = Array.isArray(finalHealth.connectedPlatforms)
485
+ ? finalHealth.connectedPlatforms.length
486
+ : 0;
487
+ const backends = Array.isArray(finalHealth.backends)
488
+ ? finalHealth.backends.filter((b) => b.authOk !== false).length
489
+ : 0;
490
+ const totalBackends = Array.isArray(finalHealth.backends) ? finalHealth.backends.length : 0;
491
+ const integrations = finalHealth.integrationModes
492
+ ? Object.values(finalHealth.integrationModes).filter((m) => m && m !== "off").length
493
+ : 0;
494
+ console.log("");
495
+ console.log(` ${platforms} platform(s) · ${backends}/${totalBackends} backend(s) ready · ${integrations} integration(s) active`);
496
+ }
497
+ } catch { /* health query racy at boot — ignore */ }
498
+ }
499
+
500
+ /**
501
+ * Wait for both daemon (/api/health) and dashboard (HTTP root) to become
502
+ * reachable. Exits early if either process dies. Returns a structured result
503
+ * so the caller can differentiate "daemon crashed" (hard failure) from
504
+ * "dashboard is just slow" (soft degradation).
505
+ */
506
+ async function verifyStartup(daemonPid, dashPid, timeoutMs = 30_000) {
507
+ const dashUrl = `http://127.0.0.1:${DASHBOARD_PORT}/`;
508
+ const deadline = Date.now() + timeoutMs;
509
+ let daemonHealthy = false;
510
+ let dashboardReady = false;
511
+ while (Date.now() < deadline) {
512
+ if (!isAlive(daemonPid)) return { daemon: false, dashboard: false };
513
+ if (!isAlive(dashPid)) return { daemon: daemonHealthy, dashboard: false };
514
+ if (!daemonHealthy) {
515
+ if (await fetchHealth()) daemonHealthy = true;
516
+ }
517
+ if (!dashboardReady) {
518
+ if (await fetchHttpOk(dashUrl, 1_500)) dashboardReady = true;
519
+ }
520
+ if (daemonHealthy && dashboardReady) return { daemon: true, dashboard: true };
521
+ await new Promise((r) => setTimeout(r, 300));
522
+ }
523
+ return { daemon: daemonHealthy, dashboard: dashboardReady };
524
+ }
525
+
526
+ async function cmdStop() {
527
+ const daemonPid = getRunningPid(DAEMON_PID_FILE);
528
+ const dashPid = getRunningPid(DASHBOARD_PID_FILE);
529
+
530
+ if (!daemonPid && !dashPid) {
531
+ console.log("Not running.");
532
+ return;
533
+ }
534
+
535
+ // Kill both in parallel
536
+ const kills = [];
537
+ if (daemonPid) kills.push(killAndWait(daemonPid, DAEMON_PID_FILE, "daemon"));
538
+ if (dashPid) kills.push(killAndWait(dashPid, DASHBOARD_PID_FILE, "dashboard"));
539
+ const results = await Promise.all(kills);
540
+
541
+ const labels = [];
542
+ if (daemonPid) labels.push(results.shift() ? "daemon stopped" : `daemon (PID ${daemonPid}) did not exit`);
543
+ if (dashPid) labels.push(results.shift() ? "dashboard stopped" : `dashboard (PID ${dashPid}) did not exit`);
544
+
545
+ for (const l of labels) console.log(` ${l}`);
546
+ console.log("Stopped.");
547
+ }
548
+
549
+ async function cmdRestart(args = []) {
550
+ const cleanContext = args.includes("--clean-context");
551
+ await cmdStop();
552
+ await new Promise((r) => setTimeout(r, 500));
553
+ if (cleanContext) {
554
+ await cleanContextDirectory();
555
+ }
556
+ await cmdStart(args.filter((a) => a !== "--clean-context"));
557
+ }
558
+
559
+ // B-007 §7 — wipe context/ and md_file_snapshots rows after stopping the
560
+ // daemon, with a tarball safety backup. Mirrors
561
+ // packages/daemon/src/core/reinstall.ts but runs from the CLI so the
562
+ // daemon does not have to serve API calls through its own deletion.
563
+ async function cleanContextDirectory() {
564
+ const contextDir = path.join(DATA_DIR, "context");
565
+ const backupDir = path.join(DATA_DIR, "backup");
566
+ const dbPath = path.join(DATA_DIR, "data", "personal_agent.db");
567
+
568
+ if (!fs.existsSync(contextDir) && !fs.existsSync(dbPath)) {
569
+ console.log("Nothing to wipe — no context/ or personal_agent.db present.");
570
+ return;
571
+ }
572
+
573
+ const plan = await enumerateContextPlan(contextDir, dbPath);
574
+ const sizeMb = (plan.totalBytes / (1024 * 1024)).toFixed(2);
575
+ console.log("");
576
+ console.log("About to WIPE the following:");
577
+ console.log(` context/ (${plan.fileCount} files, ${sizeMb} MB)`);
578
+ console.log(` md_file_snapshots (${plan.snapshotRowCount} rows)`);
579
+ console.log("");
580
+ console.log("Other SQLite tables, the OS keychain, and cache/ stay intact.");
581
+ console.log("Type CLEAN to proceed, anything else to abort:");
582
+ const confirm = await readLineFromStdin();
583
+ if (confirm.trim() !== "CLEAN") {
584
+ console.log("Aborted.");
585
+ process.exit(1);
586
+ }
587
+
588
+ if (plan.fileCount > 0) {
589
+ const backupPath = path.join(
590
+ backupDir,
591
+ `context-pre-reinstall-${new Date().toISOString().replace(/[:.]/g, "-")}.tar.gz`,
592
+ );
593
+ fs.mkdirSync(backupDir, { recursive: true });
594
+ const parent = path.dirname(contextDir);
595
+ const leaf = path.basename(contextDir);
596
+ // `tar` ships with macOS, every modern Linux, and Windows 10 1803+
597
+ // (bsdtar in System32). The flags below (`-czf -C`) work on all three.
598
+ // If `tar` is missing (older Windows), fall back to a Node tarball
599
+ // implementation lazy-loaded only on that path.
600
+ const tarOk = await runBackupTar(parent, leaf, backupPath);
601
+ if (!tarOk) {
602
+ console.error("Backup failed — aborting.");
603
+ process.exit(1);
604
+ }
605
+ console.log(` backup → ${backupPath}`);
606
+ }
607
+
608
+ if (fs.existsSync(contextDir)) {
609
+ fs.rmSync(contextDir, { recursive: true, force: true });
610
+ console.log(` wiped → ${contextDir}`);
611
+ }
612
+
613
+ // B-007 §7.1 — ancillary caches: prompts/ (regenerable), agent-sessions/
614
+ // (bound to the old layout). Missing dirs are skipped silently.
615
+ for (const sub of ["prompts", "agent-sessions"]) {
616
+ const full = path.join(DATA_DIR, sub);
617
+ if (fs.existsSync(full)) {
618
+ fs.rmSync(full, { recursive: true, force: true });
619
+ console.log(` wiped → ${full}`);
620
+ }
621
+ }
622
+
623
+ if (plan.snapshotRowCount > 0 && fs.existsSync(dbPath)) {
624
+ // The sqlite3 CLI isn't on Windows by default and isn't required on
625
+ // POSIX either. Use the workspace's better-sqlite3 (already a daemon
626
+ // dep and prebuilt for win32-x64 / linux-{x64,arm64} / darwin) so the
627
+ // CLI cleanup path is identical across platforms.
628
+ try {
629
+ const { loadBetterSqlite3 } = await import("../scripts/lib/sqlite-loader.mjs");
630
+ const Database = await loadBetterSqlite3(PROJECT_ROOT);
631
+ const db = new Database(dbPath);
632
+ try {
633
+ db.exec("DELETE FROM md_file_snapshots;");
634
+ } finally {
635
+ db.close();
636
+ }
637
+ console.log(` cleared → md_file_snapshots (${plan.snapshotRowCount} rows)`);
638
+ } catch (err) {
639
+ console.error(`Failed to clear md_file_snapshots: ${err?.message ?? err}`);
640
+ process.exit(1);
641
+ }
642
+ }
643
+ console.log("");
644
+ }
645
+
646
+ /**
647
+ * Run `tar -czf <out> -C <cwd> <leaf>`. Returns true on success. Spawns
648
+ * the system `tar` (bsdtar on macOS / Windows 10 1803+, GNU tar on Linux);
649
+ * if the binary itself is missing (ENOENT) or the spawn fails, returns
650
+ * false so the caller can decide what to do. We don't ship a Node-side
651
+ * tarball implementation: the failure case (no tar binary) is a stale
652
+ * Windows 7/8 install we don't claim to support.
653
+ */
654
+ function runBackupTar(cwd, leaf, outPath) {
655
+ return new Promise((resolve) => {
656
+ let child;
657
+ try {
658
+ child = spawn("tar", ["-czf", outPath, "-C", cwd, leaf], {
659
+ stdio: "inherit",
660
+ windowsHide: true,
661
+ });
662
+ } catch (err) {
663
+ console.error(`tar spawn failed: ${err?.message ?? err}`);
664
+ resolve(false);
665
+ return;
666
+ }
667
+ child.on("error", (err) => {
668
+ // ENOENT — `tar` not on PATH (e.g. very old Windows).
669
+ console.error(`tar not available: ${err?.message ?? err}`);
670
+ resolve(false);
671
+ });
672
+ child.on("close", (code) => resolve(code === 0));
673
+ });
674
+ }
675
+
676
+ async function enumerateContextPlan(contextDir, dbPath) {
677
+ let fileCount = 0;
678
+ let totalBytes = 0;
679
+ if (fs.existsSync(contextDir)) {
680
+ const walk = (dir) => {
681
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
682
+ const full = path.join(dir, entry.name);
683
+ if (entry.isDirectory()) walk(full);
684
+ else if (entry.isFile()) {
685
+ fileCount++;
686
+ totalBytes += fs.statSync(full).size;
687
+ }
688
+ }
689
+ };
690
+ walk(contextDir);
691
+ }
692
+ let snapshotRowCount = 0;
693
+ if (fs.existsSync(dbPath)) {
694
+ // Use better-sqlite3 (workspace dep, prebuilds for all three OSes)
695
+ // instead of the sqlite3 CLI. The CLI isn't on Windows by default and
696
+ // its absence on POSIX would silently undercount snapshots in the
697
+ // confirm prompt — better to fail loud.
698
+ try {
699
+ const { loadBetterSqlite3 } = await import("../scripts/lib/sqlite-loader.mjs");
700
+ const Database = await loadBetterSqlite3(PROJECT_ROOT);
701
+ const db = new Database(dbPath, { readonly: true });
702
+ try {
703
+ const row = db
704
+ .prepare("SELECT COUNT(*) AS n FROM md_file_snapshots")
705
+ .get();
706
+ snapshotRowCount = Number(row?.n ?? 0) || 0;
707
+ } finally {
708
+ db.close();
709
+ }
710
+ } catch {
711
+ // Table missing on a half-migrated DB, etc. — leave at 0; the daemon
712
+ // will finish cleanup on next boot.
713
+ }
714
+ }
715
+ return { fileCount, totalBytes, snapshotRowCount };
716
+ }
717
+
718
+ async function readLineFromStdin() {
719
+ return new Promise((resolve) => {
720
+ process.stdin.resume();
721
+ process.stdin.setEncoding("utf-8");
722
+ const onData = (chunk) => {
723
+ process.stdin.pause();
724
+ process.stdin.removeListener("data", onData);
725
+ resolve(chunk);
726
+ };
727
+ process.stdin.on("data", onData);
728
+ });
729
+ }
730
+
731
+ async function cmdStatus() {
732
+ const daemonPid = getRunningPid(DAEMON_PID_FILE);
733
+ const dashPid = getRunningPid(DASHBOARD_PID_FILE);
734
+
735
+ if (!daemonPid && !dashPid) {
736
+ console.log("Not running.");
737
+ process.exit(1);
738
+ }
739
+
740
+ const health = daemonPid ? await fetchHealth() : null;
741
+
742
+ console.log(`${APP_NAME} status:`);
743
+ console.log("");
744
+
745
+ // Daemon
746
+ if (daemonPid) {
747
+ const uptime = health?.uptime != null ? formatUptime(health.uptime) : "—";
748
+ console.log(` Daemon: running (PID ${daemonPid})`);
749
+ console.log(` Uptime: ${uptime}`);
750
+ console.log(` API: http://127.0.0.1:${DAEMON_PORT}`);
751
+ if (health?.connectedPlatforms?.length > 0) {
752
+ console.log(` Platforms: ${health.connectedPlatforms.join(", ")}`);
753
+ }
754
+ if (Array.isArray(health?.backends) && health.backends.length > 0) {
755
+ const summary = health.backends
756
+ .map((b) => `${b.id}${b.authOk === false ? "(auth!)" : ""}`)
757
+ .join(", ");
758
+ console.log(` Backends: ${summary}`);
759
+ }
760
+ } else {
761
+ console.log(` Daemon: not running`);
762
+ }
763
+
764
+ // Dashboard
765
+ if (dashPid) {
766
+ console.log(` Dashboard: running (PID ${dashPid})`);
767
+ console.log(` URL: http://localhost:${DASHBOARD_PORT}`);
768
+ } else {
769
+ console.log(` Dashboard: not running`);
770
+ }
771
+
772
+ // Activity / cost summary — best-effort, read-only via SQLite. Skip silently
773
+ // if the DB doesn't exist yet or the schema is unexpected.
774
+ try {
775
+ const summary = await readActivitySummary();
776
+ if (summary) {
777
+ console.log("");
778
+ console.log(` Last action: ${summary.lastActionAt ?? "—"}${summary.lastActionType ? ` (${summary.lastActionType})` : ""}`);
779
+ console.log(` Today: ${summary.actionsToday} action(s) · $${summary.costTodayUsd.toFixed(3)} spent`);
780
+ if (summary.nextScheduled) {
781
+ console.log(` Next: ${summary.nextScheduled.at} ${summary.nextScheduled.label}`);
782
+ }
783
+ }
784
+ } catch {
785
+ /* DB missing or pre-init — silent. */
786
+ }
787
+ }
788
+
789
+ /**
790
+ * Read a tiny activity summary directly from SQLite for `aitne status`.
791
+ *
792
+ * Safe to do while the daemon runs because the daemon enables WAL
793
+ * (`packages/daemon/src/db/client.ts`) — concurrent readers are fine.
794
+ * Returns `null` if the DB file doesn't exist yet (fresh install before
795
+ * the daemon's first boot) so the caller can skip the section gracefully.
796
+ */
797
+ async function readActivitySummary() {
798
+ const dbPath = path.join(DATA_DIR, "data", "personal_agent.db");
799
+ if (!fs.existsSync(dbPath)) return null;
800
+ const { loadBetterSqlite3 } = await import("../scripts/lib/sqlite-loader.mjs");
801
+ const Database = await loadBetterSqlite3(PROJECT_ROOT);
802
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
803
+ try {
804
+ const lastRow = db
805
+ .prepare("SELECT action_type, started_at FROM agent_actions ORDER BY started_at DESC LIMIT 1")
806
+ .get();
807
+ const todayRow = db
808
+ .prepare(`SELECT COUNT(*) AS n, COALESCE(SUM(cost_usd), 0) AS cost
809
+ FROM agent_actions
810
+ WHERE date(started_at) = date('now', 'localtime')`)
811
+ .get();
812
+ // Schema: agent_schedule(scheduled_for, task_type, status, …) — see
813
+ // packages/daemon/src/db/schema.ts. Status enum: pending|running|completed|skipped|failed.
814
+ let nextScheduled = null;
815
+ try {
816
+ const nextRow = db
817
+ .prepare(`SELECT scheduled_for, task_type FROM agent_schedule
818
+ WHERE status = 'pending' AND scheduled_for >= datetime('now')
819
+ ORDER BY scheduled_for ASC LIMIT 1`)
820
+ .get();
821
+ if (nextRow) {
822
+ nextScheduled = { at: nextRow.scheduled_for, label: nextRow.task_type ?? "" };
823
+ }
824
+ } catch { /* table absent on a much-older DB — skip silently */ }
825
+ return {
826
+ lastActionAt: lastRow?.started_at ?? null,
827
+ lastActionType: lastRow?.action_type ?? null,
828
+ actionsToday: Number(todayRow?.n ?? 0),
829
+ costTodayUsd: Number(todayRow?.cost ?? 0),
830
+ nextScheduled,
831
+ };
832
+ } finally {
833
+ db.close();
834
+ }
835
+ }
836
+
837
+ async function cmdLogs(args) {
838
+ const follow = args.includes("-f") || args.includes("--follow");
839
+ const isDash = args.includes("--dashboard") || args.includes("-d");
840
+ const logFile = isDash ? DASHBOARD_LOG_FILE : DAEMON_LOG_FILE;
841
+ const linesArg = (() => {
842
+ const nIdx = args.indexOf("-n");
843
+ if (nIdx !== -1 && args[nIdx + 1]) return parseInt(args[nIdx + 1], 10);
844
+ return 50;
845
+ })();
846
+ const lineCount = Number.isFinite(linesArg) && linesArg > 0 ? linesArg : 50;
847
+
848
+ if (!fs.existsSync(logFile)) {
849
+ console.log(`No log file found at ${logFile}`);
850
+ console.log("Has the service been started?");
851
+ process.exit(1);
852
+ }
853
+
854
+ await tailFile(logFile, { follow, lines: lineCount });
855
+ }
856
+
857
+ /**
858
+ * Pure-Node `tail` / `tail -f` so the CLI works on Windows (no `tail` binary)
859
+ * as well as POSIX. Trade-offs:
860
+ * - `lines` is honored by reading the whole file and slicing the last N lines.
861
+ * Daemon logs rotate at 10 MB (see `rotateLogIfNeeded` above), so reading
862
+ * them entirely is bounded.
863
+ * - `follow` polls via `fs.watchFile` (1 s interval). `fs.watch` would be
864
+ * pushier but is unreliable across Windows network drives and various
865
+ * editor write patterns; polling is dull but trustworthy.
866
+ * - On rotate or truncate the read offset is reset so we don't print stale
867
+ * bytes.
868
+ */
869
+ function tailFile(logFile, { follow, lines }) {
870
+ return new Promise((resolve) => {
871
+ let position = 0;
872
+ try {
873
+ const initial = fs.readFileSync(logFile, "utf8");
874
+ const tail = initial.split("\n");
875
+ // Trailing empty element when file ends with newline — drop so we
876
+ // don't print a blank line.
877
+ if (tail.length > 0 && tail[tail.length - 1] === "") tail.pop();
878
+ const slice = tail.slice(-lines);
879
+ if (slice.length > 0) process.stdout.write(slice.join("\n") + "\n");
880
+ position = Buffer.byteLength(initial, "utf8");
881
+ } catch (err) {
882
+ console.error(`Failed to read ${logFile}: ${err.message}`);
883
+ resolve(1);
884
+ return;
885
+ }
886
+
887
+ if (!follow) {
888
+ resolve(0);
889
+ return;
890
+ }
891
+
892
+ let watching = true;
893
+ const onChange = (curr, prev) => {
894
+ if (!watching) return;
895
+ // File was truncated or rotated — reset to the start so we don't
896
+ // emit garbage from a lost offset.
897
+ if (curr.size < position) position = 0;
898
+ if (curr.size === position) return;
899
+ try {
900
+ const fd = fs.openSync(logFile, "r");
901
+ const length = curr.size - position;
902
+ const buf = Buffer.alloc(length);
903
+ fs.readSync(fd, buf, 0, length, position);
904
+ fs.closeSync(fd);
905
+ position = curr.size;
906
+ process.stdout.write(buf.toString("utf8"));
907
+ } catch {
908
+ // Log gone (rotation race) — fs.watchFile will fire again when it
909
+ // reappears; resetting position handles the new file.
910
+ position = 0;
911
+ }
912
+ };
913
+ fs.watchFile(logFile, { interval: 1000 }, onChange);
914
+
915
+ const stop = () => {
916
+ if (!watching) return;
917
+ watching = false;
918
+ fs.unwatchFile(logFile, onChange);
919
+ resolve(0);
920
+ process.exit(0);
921
+ };
922
+ process.on("SIGINT", stop);
923
+ process.on("SIGTERM", stop);
924
+ if (IS_WINDOWS) process.on("SIGBREAK", stop);
925
+ });
926
+ }
927
+
928
+ async function cmdDev(args = []) {
929
+ // Foreground mode: daemon + dashboard with full stdio
930
+ const child = spawn(
931
+ process.execPath,
932
+ [path.join(PROJECT_ROOT, "scripts/start.mjs"), ...args],
933
+ {
934
+ cwd: PROJECT_ROOT,
935
+ stdio: "inherit",
936
+ env: process.env,
937
+ },
938
+ );
939
+ child.on("exit", (code) => process.exit(code ?? 0));
940
+ process.on("SIGINT", () => child.kill("SIGINT"));
941
+ process.on("SIGTERM", () => child.kill("SIGTERM"));
942
+ }
943
+
944
+ async function cmdBuild() {
945
+ const { runBuild, writeBuildStamp } = await import("../scripts/run-node.mjs");
946
+ const code = await runBuild(PROJECT_ROOT);
947
+ if (code === 0) {
948
+ writeBuildStamp(PROJECT_ROOT);
949
+ console.log("Build complete.");
950
+ } else {
951
+ console.error("Build failed.");
952
+ process.exit(code);
953
+ }
954
+ }
955
+
956
+ /**
957
+ * `aitne help` and `aitne help <command>`. Async so per-command help
958
+ * dispatch into a dynamically-imported module is awaited — without await,
959
+ * the runExternalCommand promise can race against process exit. In practice
960
+ * Node's I/O wait usually keeps it alive long enough, but relying on that
961
+ * is brittle.
962
+ */
963
+ async function cmdHelp(args = []) {
964
+ // Per-command help: `aitne help <cmd>` or `aitne <cmd> --help`. Defers to
965
+ // each command module's run() with --help so the long-form usage lives
966
+ // next to the command's logic, not duplicated in this dispatcher.
967
+ const target = args[0];
968
+ if (target && PER_COMMAND_HELP.has(target)) {
969
+ await runExternalCommand(target, ["--help"]);
970
+ return;
971
+ }
972
+ console.log(`${APP_NAME} v${VERSION} — local-first personal AI agent
973
+
974
+ Usage: aitne <command> [options]
975
+
976
+ Lifecycle:
977
+ start [--no-open] Build (if stale) & launch daemon + dashboard
978
+ stop Graceful shutdown (SIGTERM → SIGKILL after 10s)
979
+ restart [--no-open] [--clean-context]
980
+ Restart; --clean-context wipes context/ &
981
+ md_file_snapshots after tarball backup (B-007)
982
+ status PIDs, uptime, integrations, today's spend
983
+ logs [-f] [-n N] [-d] Tail daemon log (-d for dashboard log)
984
+ dev [--no-open] Foreground mode (development, full stdio)
985
+ build Build TypeScript explicitly
986
+
987
+ Operations:
988
+ setup Open dashboard /setup wizard
989
+ open Open dashboard root in browser
990
+ doctor Diagnose install (Node, ports, keychain, CLIs, …)
991
+ audit [--since <dur>] [--type X] Show agent action log (filterable)
992
+ version Print version + Node + install path
993
+ update Print npm command to upgrade
994
+ uninstall Stop, then offer to wipe ${path.basename(DATA_DIR) || "data dir"}
995
+
996
+ Options:
997
+ --version, -v Print version
998
+ --help, -h Show this help (or per-command help)
999
+
1000
+ Environment:
1001
+ PA_DATA_DIR Data directory (default: ~/.personal-agent)
1002
+ PA_API_PORT Daemon port (default: 8321)
1003
+ PA_DASHBOARD_PORT Dashboard port (default: 3000)
1004
+
1005
+ Examples:
1006
+ aitne start Launch in background
1007
+ aitne status Check what's running
1008
+ aitne audit --since 7d Last week's agent actions
1009
+ aitne doctor Diagnose first-install issues
1010
+ aitne logs -f Follow daemon log
1011
+
1012
+ Run 'aitne help <command>' for detailed usage of a single command.`);
1013
+ }
1014
+
1015
+ // Commands that own their own --help output (i.e. live in scripts/commands/).
1016
+ // Used by `aitne help <cmd>` to dispatch into the module rather than duplicating
1017
+ // usage strings in cmdHelp() above.
1018
+ const PER_COMMAND_HELP = new Set([
1019
+ "doctor", "audit", "setup", "open", "version", "update", "uninstall",
1020
+ ]);
1021
+
1022
+ /**
1023
+ * Dispatch into a command module under scripts/commands/. The dispatcher hands
1024
+ * the module a precomputed context (paths, ports, brand) so individual
1025
+ * commands don't re-derive shared state.
1026
+ */
1027
+ async function runExternalCommand(name, args) {
1028
+ const mod = await import(`../scripts/commands/${name}.mjs`);
1029
+ const ctx = {
1030
+ APP_NAME,
1031
+ VERSION,
1032
+ DATA_DIR,
1033
+ DAEMON_PORT,
1034
+ DASHBOARD_PORT,
1035
+ PROJECT_ROOT,
1036
+ DAEMON_PID_FILE,
1037
+ DASHBOARD_PID_FILE,
1038
+ DAEMON_LOG_FILE,
1039
+ DASHBOARD_LOG_FILE,
1040
+ IS_WINDOWS,
1041
+ helpers: {
1042
+ getRunningPid,
1043
+ formatUptime,
1044
+ fetchHealth,
1045
+ openBrowser,
1046
+ cmdStart,
1047
+ cmdStop,
1048
+ },
1049
+ };
1050
+ return mod.run(args, ctx);
1051
+ }
1052
+
1053
+ // ── Main ──
1054
+
1055
+ const subcommand = process.argv[2];
1056
+ const subArgs = process.argv.slice(3);
1057
+
1058
+ switch (subcommand) {
1059
+ case "_log-runner": await cmdLogRunner(subArgs); break;
1060
+ case "start": await cmdStart(subArgs); break;
1061
+ case "stop": await cmdStop(); break;
1062
+ case "restart": await cmdRestart(subArgs); break;
1063
+ case "status": await cmdStatus(); break;
1064
+ case "logs": await cmdLogs(subArgs); break;
1065
+ case "dev": await cmdDev(subArgs); break;
1066
+ case "build": await cmdBuild(); break;
1067
+
1068
+ case "setup": await runExternalCommand("setup", subArgs); break;
1069
+ case "open": await runExternalCommand("open", subArgs); break;
1070
+ case "doctor": await runExternalCommand("doctor", subArgs); break;
1071
+ case "audit": await runExternalCommand("audit", subArgs); break;
1072
+
1073
+ // version / -v / --version all dispatch identically — passing subArgs
1074
+ // through so `aitne --version --json` yields the same output as
1075
+ // `aitne version --json`. (Earlier code dropped subArgs in the
1076
+ // short-circuit path, producing inconsistent behavior.)
1077
+ case "version":
1078
+ case "--version":
1079
+ case "-v":
1080
+ await runExternalCommand("version", subArgs); break;
1081
+
1082
+ case "update": await runExternalCommand("update", subArgs); break;
1083
+ case "uninstall": await runExternalCommand("uninstall", subArgs); break;
1084
+
1085
+ case "help":
1086
+ case "--help":
1087
+ case "-h":
1088
+ await cmdHelp(subArgs); break;
1089
+ default:
1090
+ if (subcommand) {
1091
+ console.error(`Unknown command: ${subcommand}`);
1092
+ console.error(`Run 'aitne help' for the list of commands.`);
1093
+ process.exit(1);
1094
+ }
1095
+ await cmdHelp();
1096
+ }