@agentprojectcontext/apx 1.76.0 → 1.77.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 (27) hide show
  1. package/package.json +1 -1
  2. package/src/core/net/lan.js +114 -0
  3. package/src/core/profiles/bundled/secretary/PROFILE.md +6 -5
  4. package/src/core/profiles/bundled/secretary/profile.json +19 -6
  5. package/src/core/stores/agent-inbox.js +153 -0
  6. package/src/core/stores/conversations.js +21 -1
  7. package/src/host/daemon/api/inbox.js +45 -0
  8. package/src/host/daemon/api/web.js +2 -2
  9. package/src/host/daemon/api.js +2 -0
  10. package/src/interfaces/cli/commands/panel.js +131 -0
  11. package/src/interfaces/cli/index.js +28 -0
  12. package/src/interfaces/web/dist/assets/{index-Bjlk9ttU.js → index-BWWpTTSd.js} +135 -135
  13. package/src/interfaces/web/dist/assets/index-BWWpTTSd.js.map +1 -0
  14. package/src/interfaces/web/dist/assets/index-D_EJEA1n.css +1 -0
  15. package/src/interfaces/web/dist/index.html +2 -2
  16. package/src/interfaces/web/src/App.tsx +2 -0
  17. package/src/interfaces/web/src/components/layout/ProjectSidebar.tsx +20 -1
  18. package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +105 -72
  19. package/src/interfaces/web/src/hooks/useInbox.ts +12 -0
  20. package/src/interfaces/web/src/i18n/en.ts +16 -0
  21. package/src/interfaces/web/src/i18n/es.ts +16 -0
  22. package/src/interfaces/web/src/lib/api/inbox.ts +26 -0
  23. package/src/interfaces/web/src/screens/InboxScreen.tsx +103 -0
  24. package/src/interfaces/web/src/screens/SettingsScreen.tsx +1 -1
  25. package/src/core/profiles/bundled/secretary/PROFILE.es.md +0 -44
  26. package/src/interfaces/web/dist/assets/index-Bjlk9ttU.js.map +0 -1
  27. package/src/interfaces/web/dist/assets/index-CQ5kyFej.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.76.0",
3
+ "version": "1.77.0",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -0,0 +1,114 @@
1
+ // LAN address discovery, for reaching the panel from a phone on the same
2
+ // network.
3
+ //
4
+ // This is deliberately NOT a tunnel. Nothing leaves the local network: the
5
+ // daemon binds a second, specific interface address and that is all. The threat
6
+ // model of a home LAN is not the threat model of a guessable public hostname,
7
+ // which is why this is acceptable where a public tunnel is not — see
8
+ // docs-internal/secretary/04-BACKLOG-agent-inbox.md § C.
9
+ import os from "node:os";
10
+
11
+ /** Bind addresses that are never chosen automatically. */
12
+ const LOOPBACK = new Set(["127.0.0.1", "::1"]);
13
+
14
+ /**
15
+ * Every non-internal IPv4 address on this machine, best candidate first.
16
+ *
17
+ * Ordering matters because the first one is what `apx panel share` will pick:
18
+ * ordinary private ranges (a home or office network) come before link-local
19
+ * autoconfiguration addresses, which usually mean "no DHCP happened" and are
20
+ * rarely what someone wants to type into a phone.
21
+ *
22
+ * @returns {{ address: string, iface: string, cidr: string|null, private: boolean }[]}
23
+ */
24
+ export function detectLanAddresses() {
25
+ const out = [];
26
+ const ifaces = os.networkInterfaces();
27
+
28
+ for (const [iface, addrs] of Object.entries(ifaces || {})) {
29
+ for (const a of addrs || []) {
30
+ if (!a || a.internal) continue;
31
+ // Node <18.4 reported family as the string "IPv4"; newer versions use 4.
32
+ if (a.family !== "IPv4" && a.family !== 4) continue;
33
+ if (LOOPBACK.has(a.address)) continue;
34
+ out.push({
35
+ address: a.address,
36
+ iface,
37
+ cidr: a.cidr || null,
38
+ private: isPrivateIPv4(a.address),
39
+ });
40
+ }
41
+ }
42
+
43
+ return out.sort((x, y) => {
44
+ // Real private addresses first, link-local last.
45
+ const rank = (v) => (isLinkLocal(v.address) ? 2 : v.private ? 0 : 1);
46
+ const d = rank(x) - rank(y);
47
+ return d !== 0 ? d : x.address.localeCompare(y.address);
48
+ });
49
+ }
50
+
51
+ /** RFC1918 plus carrier-grade NAT — "an address someone else's router gave me". */
52
+ export function isPrivateIPv4(address) {
53
+ const p = String(address || "").split(".").map(Number);
54
+ if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false;
55
+ const [a, b] = p;
56
+ if (a === 10) return true;
57
+ if (a === 172 && b >= 16 && b <= 31) return true;
58
+ if (a === 192 && b === 168) return true;
59
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT (e.g. Tailscale)
60
+ return false;
61
+ }
62
+
63
+ /** 169.254.0.0/16 — self-assigned when no DHCP answered. */
64
+ export function isLinkLocal(address) {
65
+ const p = String(address || "").split(".").map(Number);
66
+ return p[0] === 169 && p[1] === 254;
67
+ }
68
+
69
+ export function isLoopback(address) {
70
+ return LOOPBACK.has(String(address || "").trim());
71
+ }
72
+
73
+ /** Binds every interface, present and future. Never chosen automatically. */
74
+ export function isWildcard(address) {
75
+ const h = String(address || "").trim();
76
+ return h === "0.0.0.0" || h === "::" || h === "*";
77
+ }
78
+
79
+ /**
80
+ * Validate a host the user asked to bind.
81
+ *
82
+ * `0.0.0.0` is refused on purpose. It binds every interface present now AND
83
+ * every one that appears later — a VPN, a hotspot, a bridged container — which
84
+ * is a different and much larger promise than "reachable on my home network".
85
+ * The specific address is always available instead.
86
+ *
87
+ * @returns {{ ok: boolean, reason?: string }}
88
+ */
89
+ export function validateBindHost(host) {
90
+ const h = String(host || "").trim();
91
+ if (!h) return { ok: false, reason: "no host given" };
92
+
93
+ if (h === "0.0.0.0" || h === "::" || h.toLowerCase() === "any") {
94
+ return {
95
+ ok: false,
96
+ reason:
97
+ "0.0.0.0 binds every interface, including ones that appear later (a VPN, a hotspot, " +
98
+ "a bridged container). Bind a specific address instead — `apx panel share` picks one.",
99
+ };
100
+ }
101
+
102
+ if (isLoopback(h)) return { ok: true };
103
+
104
+ const known = detectLanAddresses().map((a) => a.address);
105
+ if (!known.includes(h)) {
106
+ return {
107
+ ok: false,
108
+ reason:
109
+ `${h} is not an address of this machine` +
110
+ (known.length ? ` — available: ${known.join(", ")}` : " (no external interface found)"),
111
+ };
112
+ }
113
+ return { ok: true };
114
+ }
@@ -19,10 +19,10 @@ through you is anchored to a project registered in APX.
19
19
 
20
20
  ## How you work
21
21
 
22
- **Capture by default, ask rarely.** When a task surfaces in conversation, record it
23
- yourself. Infer the project when you reasonably can and say in one line what you filed and
24
- where. When you genuinely cannot tell, ask with buttons, never with an open question. The
25
- system dies the day recording something costs more than not recording it.
22
+ **Capture by default, ask rarely.** When a task surfaces, record it yourself. Infer the
23
+ project when you can and say in one line what you filed and where. When you genuinely
24
+ cannot tell, ask with buttons, never an open question. The system dies the day recording
25
+ something costs more than not recording it.
26
26
 
27
27
  **Tasks and commitments are different.** A task is work to be done. A commitment was
28
28
  promised to a specific person, with a date; breaking it has a relational cost. Commitments
@@ -33,7 +33,8 @@ recorded on X since Y". That is useful. A fabricated summary destroys trust in t
33
33
  system and it does not come back. Always prefer the explicit gap over the tidy assumption.
34
34
 
35
35
  **Write like someone who knows the subject.** Short sentences. No decorative headers, no
36
- greeting rituals, no six bullets where two sentences do. What matters goes first.
36
+ greeting rituals, no six bullets where two sentences do. What matters goes first. These
37
+ instructions are in English; write to {{owner_name}} in their language.
37
38
 
38
39
  **Delegate domain work.** You coordinate; you do not do it. Anything involving code goes
39
40
  through the development agent. When several specialists report back, you consolidate —
@@ -5,16 +5,29 @@
5
5
  "description": "Chief of staff for someone running several projects at once. Keeps their state alive, captures what is said, and warns before something breaks.",
6
6
  "author": "apx",
7
7
  "apx_min_version": "1.74.1",
8
- "languages": ["en", "es"],
8
+ "languages": [
9
+ "en"
10
+ ],
9
11
  "provides": {
10
- "routines": ["day-open", "day-close"],
11
- "channels": ["routine"]
12
+ "routines": [
13
+ "day-open",
14
+ "day-close"
15
+ ],
16
+ "channels": [
17
+ "routine"
18
+ ]
12
19
  },
13
20
  "requires": {
14
- "capabilities": ["routine.memory"],
21
+ "capabilities": [
22
+ "routine.memory"
23
+ ],
15
24
  "integrations": [],
16
- "optional_integrations": ["calendar"],
17
- "channels": ["telegram"]
25
+ "optional_integrations": [
26
+ "calendar"
27
+ ],
28
+ "channels": [
29
+ "telegram"
30
+ ]
18
31
  },
19
32
  "prompt_budget_tokens": 600
20
33
  }
@@ -0,0 +1,153 @@
1
+ // The agent inbox — every agent as a conversation, most recent first.
2
+ //
3
+ // APX is navigated project-first: pick a project, then a tab, then an agent.
4
+ // The inbox inverts that. The unit becomes the CONVERSATION WITH AN AGENT and
5
+ // the project becomes an attribute of it, which is what someone running several
6
+ // projects at once actually wants as a daily entry point.
7
+ //
8
+ // It is a second axis, NOT a replacement. Project-first navigation stays intact
9
+ // — projects as a first-class unit with versioned context is what APX has that
10
+ // a personal assistant does not, and the inbox must not erode it.
11
+ //
12
+ // Same shape as listTasksAcrossProjects in stores/tasks.js, for the same
13
+ // reasons: the caller supplies the project list so core stays free of daemon
14
+ // imports, an unreadable project is skipped and named rather than fatal, and
15
+ // ordering has a deterministic tiebreak because nowIso() only has second
16
+ // resolution.
17
+ import { readAgents } from "../apc/parser.js";
18
+ import { listConversations } from "./conversations.js";
19
+ import { listGlobalThreads, readGlobalThread } from "./messages.js";
20
+ import { SUPERAGENT_ACTOR_ID } from "../constants/actors.js";
21
+
22
+ /** Most recent first; slug breaks ties so two identical calls agree. */
23
+ function byRecency(a, b) {
24
+ const t = (b.last_activity_at || "").localeCompare(a.last_activity_at || "");
25
+ if (t !== 0) return t;
26
+ return String(a.agent_slug || "").localeCompare(String(b.agent_slug || ""));
27
+ }
28
+
29
+ /**
30
+ * One row per agent that has a conversation, plus the super-agent.
31
+ *
32
+ * @param {{id:any, name?:string, path?:string, storagePath:string}[]} projects
33
+ * @param {object} opts
34
+ * - limit cap applied AFTER the merge
35
+ * - includeEmpty also list agents that have never been talked to
36
+ * @returns {{ rows: object[], skipped: {id:any, error:string}[] }}
37
+ */
38
+ export function listAgentInbox(projects, opts = {}) {
39
+ const { limit, includeEmpty = false } = opts || {};
40
+ const rows = [];
41
+ const skipped = [];
42
+
43
+ for (const entry of projects || []) {
44
+ if (!entry?.storagePath) continue;
45
+ const projectMeta = {
46
+ project_id: entry.id,
47
+ project_name: entry.name || entry.path || String(entry.id),
48
+ project_path: entry.path || null,
49
+ };
50
+
51
+ let agents = [];
52
+ try {
53
+ agents = entry.path ? readAgents(entry.path) : [];
54
+ } catch (e) {
55
+ skipped.push({ id: entry.id, error: e?.message || String(e) });
56
+ continue;
57
+ }
58
+
59
+ for (const agent of agents) {
60
+ let conversations = [];
61
+ try {
62
+ conversations = listConversations(entry.storagePath, agent.slug);
63
+ } catch {
64
+ // One agent's unreadable conversation directory must not drop the
65
+ // whole project from the inbox.
66
+ conversations = [];
67
+ }
68
+ const latest = conversations[0] || null;
69
+ if (!latest && !includeEmpty) continue;
70
+
71
+ rows.push({
72
+ ...projectMeta,
73
+ agent_slug: agent.slug,
74
+ agent_name: agent.fields?.Name || agent.name || agent.slug,
75
+ agent_emoji: agent.fields?.Emoji || agent.emoji || null,
76
+ kind: "agent",
77
+ pinned: false,
78
+ conversation_id: latest?.id || null,
79
+ channel: latest?.channel || null,
80
+ messages: latest?.messages || 0,
81
+ // The agent's last REPLY, not the user's last prompt.
82
+ preview: latest?.preview || null,
83
+ last_activity_at: latest?.last_turn_at || latest?.started_at || "",
84
+ });
85
+ }
86
+ }
87
+
88
+ rows.sort(byRecency);
89
+
90
+ // The super-agent is the single voice the owner talks to and the others
91
+ // report through it. It is pinned first and marked distinct so the hierarchy
92
+ // is visible, rather than sorted in among its own reports.
93
+ const superRow = buildSuperAgentRow();
94
+ const out = superRow ? [superRow, ...rows] : rows;
95
+
96
+ return {
97
+ rows: Number.isFinite(limit) && limit > 0 ? out.slice(0, limit) : out,
98
+ skipped,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * The pinned super-agent row.
104
+ *
105
+ * The super-agent does NOT keep per-agent conversation files the way project
106
+ * agents do — it talks on channels, and its history is the cross-channel ledger
107
+ * (~/.apx/messages/<channel>/YYYY-MM-DD.jsonl). So recency and the preview come
108
+ * from there, not from agents/<slug>/conversations.
109
+ */
110
+ function buildSuperAgentRow() {
111
+ let threads = [];
112
+ try {
113
+ threads = listGlobalThreads();
114
+ } catch {
115
+ threads = [];
116
+ }
117
+
118
+ const messages = threads.reduce((n, t) => n + (t.messages || 0), 0);
119
+ const latest = threads[0] || null; // listGlobalThreads sorts by last_ts desc
120
+
121
+ let preview = null;
122
+ if (latest) {
123
+ try {
124
+ const thread = readGlobalThread({ channel: latest.channel, date: latest.id });
125
+ const lastReply = [...(thread?.messages || [])]
126
+ .reverse()
127
+ .find((m) => m.role === "assistant");
128
+ preview = (lastReply?.content || "")
129
+ .replace(/```[\s\S]*?```/g, " ")
130
+ .replace(/\s+/g, " ")
131
+ .trim()
132
+ .slice(0, 160) || null;
133
+ } catch {
134
+ preview = null;
135
+ }
136
+ }
137
+
138
+ return {
139
+ project_id: null,
140
+ project_name: null,
141
+ project_path: null,
142
+ agent_slug: SUPERAGENT_ACTOR_ID,
143
+ agent_name: null, // resolved by the surface via resolveAgentName()
144
+ agent_emoji: null,
145
+ kind: "super_agent",
146
+ pinned: true,
147
+ conversation_id: latest?.id || null,
148
+ channel: latest?.channel || null,
149
+ messages,
150
+ preview,
151
+ last_activity_at: latest?.last_ts || "",
152
+ };
153
+ }
@@ -73,7 +73,13 @@ export function parseConversation(text) {
73
73
  body = text.slice(fmEnd + 4);
74
74
  }
75
75
  const turns = [];
76
- const re = /^##\s+(user|assistant|system|tool|compact)\s+—\s+(\S+)\s*\n([\s\S]*?)(?=\n##\s+(?:user|assistant|system|tool|compact)\s+—\s|\n*$)/gm;
76
+ // The terminator is "the next turn header, or the true end of input".
77
+ //
78
+ // It used to be `\n*$`, and with the /m flag `$` matches the end of any LINE
79
+ // — so the lazy body stopped at the first newline and every multi-line turn
80
+ // was silently truncated to its first line. `(?![\s\S])` is end-of-input and
81
+ // nothing else. /m is still needed for the `^` on the header.
82
+ const re = /^##\s+(user|assistant|system|tool|compact)\s+—\s+(\S+)\s*\n([\s\S]*?)(?=\n##\s+(?:user|assistant|system|tool|compact)\s+—\s|\s*(?![\s\S]))/gm;
77
83
  let m;
78
84
  while ((m = re.exec(body)) !== null) {
79
85
  turns.push({
@@ -124,15 +130,29 @@ function summarizeConversation(filePath, agentSlug, filename) {
124
130
  const messages = turns.filter((t) => t.role !== "system" && t.role !== "compact").length;
125
131
  const firstUser = turns.find((t) => t.role === "user");
126
132
  const title = (firstUser?.content || "").split("\n")[0].slice(0, 80).trim() || undefined;
133
+
134
+ // What the AGENT last said, not what the user last asked. An inbox row that
135
+ // echoes your own prompt back tells you nothing; the reply is the thing you
136
+ // want to see without opening the thread ("report filed, nothing over policy").
137
+ const lastReply = [...turns].reverse().find((t) => t.role === "assistant");
138
+ const preview = (lastReply?.content || "")
139
+ .replace(/```[\s\S]*?```/g, " ") // code fences read as noise at one line
140
+ .replace(/\s+/g, " ")
141
+ .trim()
142
+ .slice(0, 160) || undefined;
143
+
127
144
  return {
128
145
  id: filename.replace(/\.md$/, ""),
129
146
  filename,
130
147
  agent_slug: agentSlug,
131
148
  started_at: fm.started || fm.last_turn || "",
149
+ last_turn_at: fm.last_turn || fm.started || "",
132
150
  ended_at: fm.status === "closed" ? (fm.last_turn || undefined) : undefined,
133
151
  channel: fm.channel || undefined,
134
152
  messages,
135
153
  title,
154
+ preview,
155
+ preview_at: lastReply?.ts || undefined,
136
156
  };
137
157
  }
138
158
 
@@ -0,0 +1,45 @@
1
+ // GET /inbox every agent as a conversation, most recent first, super-agent pinned
2
+ // ?limit=N&include_empty=1
3
+ //
4
+ // The conversation-first entry point. Project-first navigation is unaffected —
5
+ // this is a second axis over the same data, not a replacement for it.
6
+ import { listAgentInbox } from "#core/stores/agent-inbox.js";
7
+ import { readConfig } from "#core/config/index.js";
8
+ import { resolveAgentName } from "#core/identity/index.js";
9
+ import { pageEnvelope } from "./shared.js";
10
+
11
+ export function register(app, { projects }) {
12
+ app.get("/inbox", (req, res) => {
13
+ try {
14
+ const entries = [];
15
+ for (const entry of projects.list()) {
16
+ const p = projects.get(entry.id);
17
+ if (!p?.storagePath) continue;
18
+ entries.push({
19
+ id: entry.id,
20
+ name: entry.name || entry.path,
21
+ path: entry.path,
22
+ storagePath: p.storagePath,
23
+ });
24
+ }
25
+
26
+ const { rows, skipped } = listAgentInbox(entries, {
27
+ includeEmpty: req.query.include_empty === "1" || req.query.include_empty === "true",
28
+ });
29
+
30
+ // The super-agent's display name lives in identity.json, and core must not
31
+ // reach for it — resolve it here, at the surface (AGENTS.md rule 4).
32
+ const cfg = readConfig();
33
+ const superName = resolveAgentName(cfg);
34
+ const named = rows.map((r) =>
35
+ r.kind === "super_agent" ? { ...r, agent_name: r.agent_name || superName } : r
36
+ );
37
+
38
+ const envelope = pageEnvelope(named, req.query);
39
+ if (skipped.length) envelope.meta = { ...(envelope.meta || {}), skipped };
40
+ res.json(envelope);
41
+ } catch (e) {
42
+ res.status(500).json({ error: e.message });
43
+ }
44
+ });
45
+ }
@@ -24,7 +24,7 @@ const API_PREFIXES = [
24
24
  "/health", "/admin", "/projects", "/telegram", "/engines", "/runtimes",
25
25
  "/messages", "/sessions", "/tools", "/mcp", "/voice", "/tts", "/desktop", "/overlay",
26
26
  "/transcribe", "/run", "/files", "/memory", "/env", "/pair", "/deck",
27
- "/super-agent", "/identity", "/skills", "/profiles",
27
+ "/super-agent", "/identity", "/skills", "/profiles", "/inbox",
28
28
  ];
29
29
 
30
30
  export function isApiPath(p) {
@@ -42,7 +42,7 @@ export function isApiPath(p) {
42
42
  const SPA_ROUTES = [
43
43
  /^\/$/,
44
44
  /^\/settings(\/.*)?$/,
45
- /^\/m\/(voice|desktop|deck|code)(\/.*)?$/,
45
+ /^\/m\/(voice|desktop|deck|code|inbox)(\/.*)?$/,
46
46
  /^\/p\/[^/]+(\/.*)?$/,
47
47
  ];
48
48
 
@@ -52,6 +52,7 @@ import { register as registerAdmin } from "./api/admin.js";
52
52
  import { register as registerAdminConfig } from "./api/admin-config.js";
53
53
  import { register as registerIdentity } from "./api/identity.js";
54
54
  import { register as registerProfiles } from "./api/profiles.js";
55
+ import { register as registerInbox } from "./api/inbox.js";
55
56
  import { register as registerWeb } from "./api/web.js";
56
57
  import { register as registerConfirm } from "./api/confirm.js";
57
58
 
@@ -153,6 +154,7 @@ export function buildApi({
153
154
  registerAdminConfig(app, ctx);
154
155
  registerIdentity(app, ctx);
155
156
  registerProfiles(app, ctx);
157
+ registerInbox(app, ctx);
156
158
 
157
159
  // ---- Web admin panel (static SPA, must mount before 404) ---------
158
160
  // Serves src/interfaces/web/dist when present + the /admin/web-token
@@ -0,0 +1,131 @@
1
+ // apx panel — reach the admin panel from another device on the same network.
2
+ //
3
+ // apx panel status
4
+ // apx panel share [--host 192.168.1.40]
5
+ // apx panel unshare
6
+ //
7
+ // This binds a second, specific LAN address. It is NOT a tunnel: nothing leaves
8
+ // the local network. Loopback stays the default, sharing is always explicit,
9
+ // and the daemon's auth is untouched — the URL carries the token because
10
+ // /admin/web-token is loopback-only by design, so a phone cannot fetch it.
11
+ import fs from "node:fs";
12
+ import { readConfig, writeConfig, effectivePort, effectiveHost } from "#core/config/index.js";
13
+ import { TOKEN_PATH } from "#core/config/paths.js";
14
+ import { detectLanAddresses, validateBindHost, isLoopback, isWildcard } from "#core/net/lan.js";
15
+
16
+ export const PANEL_USAGE = {
17
+ status: "apx panel status",
18
+ share: "apx panel share [--host <ip>]",
19
+ unshare: "apx panel unshare",
20
+ };
21
+
22
+ function readToken() {
23
+ try {
24
+ return fs.readFileSync(TOKEN_PATH, "utf8").trim();
25
+ } catch {
26
+ return "";
27
+ }
28
+ }
29
+
30
+ function panelUrl(host, port, token) {
31
+ // /admin/web-token refuses anything that is not loopback, so the panel on a
32
+ // phone can only authenticate from the fragment. The fragment never reaches
33
+ // the server or a proxy log — that is why it is a fragment and not a query.
34
+ return `http://${host}:${port}/` + (token ? `#token=${token}` : "");
35
+ }
36
+
37
+ export async function cmdPanelStatus() {
38
+ const cfg = readConfig();
39
+ const host = effectiveHost(cfg);
40
+ const port = effectivePort(cfg);
41
+
42
+ if (isLoopback(host)) {
43
+ console.log(`panel: local only — http://${host}:${port}`);
44
+ console.log(" Nothing on your network can reach it.");
45
+ console.log(" To reach it from your phone: apx panel share");
46
+ return;
47
+ }
48
+
49
+ // 0.0.0.0 is a bigger promise than "reachable on my network": it also covers
50
+ // every interface that appears LATER — a VPN, a hotspot, a bridged container.
51
+ // Worth calling out separately rather than reporting it as ordinary sharing.
52
+ if (isWildcard(host)) {
53
+ console.log(`panel: bound to EVERY interface — ${host}:${port}`);
54
+ const addrs = detectLanAddresses();
55
+ if (addrs.length) {
56
+ console.log(` Currently reachable at: ${addrs.map((a) => `http://${a.address}:${port}`).join(", ")}`);
57
+ }
58
+ console.log(" This also covers interfaces that appear later — a VPN, a hotspot, a");
59
+ console.log(" container bridge. Auth still applies, but the surface is wider than");
60
+ console.log(" it needs to be.");
61
+ console.log("");
62
+ console.log(" Narrow it to one address: apx panel share");
63
+ console.log(" Turn it off entirely: apx panel unshare");
64
+ return;
65
+ }
66
+
67
+ console.log(`panel: SHARED on your network — http://${host}:${port}`);
68
+ console.log(` Anyone on this network who has the URL and the token can open it.`);
69
+ console.log(` To stop: apx panel unshare`);
70
+ }
71
+
72
+ export async function cmdPanelShare(args) {
73
+ const cfg = readConfig();
74
+ const port = effectivePort(cfg);
75
+
76
+ const requested = args?.flags?.host;
77
+ const candidates = detectLanAddresses();
78
+
79
+ if (!requested && candidates.length === 0) {
80
+ console.error("apx panel share: no network address found on this machine.");
81
+ console.error(" Are you connected to a network? Pass one explicitly with --host <ip>.");
82
+ process.exit(1);
83
+ }
84
+
85
+ const host = requested || candidates[0].address;
86
+ const check = validateBindHost(host);
87
+ if (!check.ok) {
88
+ console.error(`apx panel share: ${check.reason}`);
89
+ process.exit(1);
90
+ }
91
+
92
+ cfg.host = host;
93
+ writeConfig(cfg);
94
+
95
+ const token = readToken();
96
+ const iface = candidates.find((c) => c.address === host)?.iface;
97
+
98
+ console.log(`panel shared on ${host}:${port}${iface ? ` (${iface})` : ""}`);
99
+ console.log("");
100
+ console.log(" Open this on your phone — the token is in the URL:");
101
+ console.log(` ${panelUrl(host, port, token)}`);
102
+ console.log("");
103
+ // Say plainly what changed. A LAN can be a café or a coworking space.
104
+ console.log(" What this means: anyone on this network can now REACH the panel.");
105
+ console.log(" They still need the token above, which is not guessable — treat that");
106
+ console.log(" URL like a password, and run `apx panel unshare` when you are done.");
107
+ if (candidates.length > 1) {
108
+ const others = candidates.filter((c) => c.address !== host).map((c) => `${c.address} (${c.iface})`);
109
+ console.log(` Other addresses on this machine: ${others.join(", ")}`);
110
+ }
111
+ console.log("");
112
+ console.log(" Restart the daemon for the new binding to take effect: apx restart");
113
+ }
114
+
115
+ export async function cmdPanelUnshare() {
116
+ const cfg = readConfig();
117
+ const port = effectivePort(cfg);
118
+ const was = effectiveHost(cfg);
119
+
120
+ if (isLoopback(was)) {
121
+ console.log("panel is already local only — nothing to do");
122
+ return;
123
+ }
124
+
125
+ cfg.host = "127.0.0.1";
126
+ writeConfig(cfg);
127
+
128
+ console.log(`panel is local only again — http://127.0.0.1:${port}`);
129
+ console.log(` Was ${was}. Nothing on your network can reach it now.`);
130
+ console.log(" Restart the daemon to apply: apx restart");
131
+ }
@@ -161,6 +161,11 @@ import {
161
161
  cmdProfileDoctor,
162
162
  cmdProfileUninstall,
163
163
  } from "./commands/profile.js";
164
+ import {
165
+ cmdPanelStatus,
166
+ cmdPanelShare,
167
+ cmdPanelUnshare,
168
+ } from "./commands/panel.js";
164
169
  import {
165
170
  cmdOrgShow,
166
171
  cmdOrgAreaAdd,
@@ -1617,6 +1622,19 @@ const HELP_TOPICS = new Map(Object.entries({
1617
1622
  examples: ["apx plugins status telegram"],
1618
1623
  }),
1619
1624
 
1625
+ panel: topic({
1626
+ title: "apx panel",
1627
+ summary:
1628
+ "Reach the admin panel from another device on the same network. Not a tunnel — nothing leaves your LAN, and loopback stays the default.",
1629
+ usage: ["apx panel <status|share|unshare>"],
1630
+ commands: [
1631
+ ["status", "Whether the panel is local only or shared, and on what address."],
1632
+ ["share", "Bind a LAN address and print the URL to open on your phone."],
1633
+ ["unshare", "Go back to local only."],
1634
+ ],
1635
+ options: [["--host <ip>", "share: bind this address instead of the detected one."]],
1636
+ examples: ["apx panel status", "apx panel share", "apx panel unshare"],
1637
+ }),
1620
1638
  profile: topic({
1621
1639
  title: "apx profile",
1622
1640
  summary:
@@ -2805,6 +2823,16 @@ async function dispatch(cmd, rest) {
2805
2823
  break;
2806
2824
  }
2807
2825
 
2826
+ case "panel": {
2827
+ const sub = rest[0];
2828
+ const a = parseArgs(rest.slice(1));
2829
+ if (!sub || sub === "status") await cmdPanelStatus(a);
2830
+ else if (sub === "share") await cmdPanelShare(a);
2831
+ else if (sub === "unshare") await cmdPanelUnshare(a);
2832
+ else die(`unknown panel subcommand: ${sub}\nUsage: apx panel <status|share|unshare>`);
2833
+ break;
2834
+ }
2835
+
2808
2836
  case "profile":
2809
2837
  case "profiles": {
2810
2838
  const sub = rest[0];