@agentprojectcontext/apx 1.75.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.
- package/package.json +1 -1
- package/src/core/net/lan.js +114 -0
- package/src/core/profiles/bundled/secretary/PROFILE.md +6 -5
- package/src/core/profiles/bundled/secretary/profile.json +19 -6
- package/src/core/runtime-skills/apx-task/SKILL.md +4 -0
- package/src/core/stores/agent-inbox.js +153 -0
- package/src/core/stores/conversations.js +21 -1
- package/src/core/stores/tasks.js +70 -1
- package/src/host/daemon/api/inbox.js +45 -0
- package/src/host/daemon/api/tasks.js +36 -15
- package/src/host/daemon/api/web.js +2 -2
- package/src/host/daemon/api.js +2 -0
- package/src/interfaces/cli/commands/panel.js +131 -0
- package/src/interfaces/cli/commands/task.js +44 -13
- package/src/interfaces/cli/index.js +35 -1
- package/src/interfaces/web/dist/assets/{index-CXeqTvfy.js → index-BWWpTTSd.js} +135 -135
- package/src/interfaces/web/dist/assets/index-BWWpTTSd.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-D_EJEA1n.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/src/App.tsx +2 -0
- package/src/interfaces/web/src/components/layout/ProjectSidebar.tsx +20 -1
- package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +105 -72
- package/src/interfaces/web/src/hooks/useInbox.ts +12 -0
- package/src/interfaces/web/src/i18n/en.ts +17 -0
- package/src/interfaces/web/src/i18n/es.ts +17 -0
- package/src/interfaces/web/src/lib/api/inbox.ts +26 -0
- package/src/interfaces/web/src/lib/api/tasks.ts +6 -2
- package/src/interfaces/web/src/screens/InboxScreen.tsx +103 -0
- package/src/interfaces/web/src/screens/SettingsScreen.tsx +1 -1
- package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +21 -3
- package/src/core/profiles/bundled/secretary/PROFILE.es.md +0 -44
- package/src/interfaces/web/dist/assets/index-CQ5kyFej.css +0 -1
- package/src/interfaces/web/dist/assets/index-CXeqTvfy.js.map +0 -1
package/package.json
CHANGED
|
@@ -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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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": [
|
|
8
|
+
"languages": [
|
|
9
|
+
"en"
|
|
10
|
+
],
|
|
9
11
|
"provides": {
|
|
10
|
-
"routines": [
|
|
11
|
-
|
|
12
|
+
"routines": [
|
|
13
|
+
"day-open",
|
|
14
|
+
"day-close"
|
|
15
|
+
],
|
|
16
|
+
"channels": [
|
|
17
|
+
"routine"
|
|
18
|
+
]
|
|
12
19
|
},
|
|
13
20
|
"requires": {
|
|
14
|
-
"capabilities": [
|
|
21
|
+
"capabilities": [
|
|
22
|
+
"routine.memory"
|
|
23
|
+
],
|
|
15
24
|
"integrations": [],
|
|
16
|
-
"optional_integrations": [
|
|
17
|
-
|
|
25
|
+
"optional_integrations": [
|
|
26
|
+
"calendar"
|
|
27
|
+
],
|
|
28
|
+
"channels": [
|
|
29
|
+
"telegram"
|
|
30
|
+
]
|
|
18
31
|
},
|
|
19
32
|
"prompt_budget_tokens": 600
|
|
20
33
|
}
|
|
@@ -17,6 +17,10 @@ apx task add "Demo for tester X" --project iacrmar --agent reviewer --tag demo -
|
|
|
17
17
|
|
|
18
18
|
# List (defaults to open)
|
|
19
19
|
apx task list --project iacrmar
|
|
20
|
+
apx task list --all # every registered project, each row labelled
|
|
21
|
+
apx task list --all --status blocked # what is stuck, everywhere
|
|
22
|
+
apx task list --all --updated-since 2026-08-01T00:00:00Z # what moved
|
|
23
|
+
apx task list --project iacrmar --status in_review
|
|
20
24
|
apx task list --project iacrmar --state all
|
|
21
25
|
apx task list --project iacrmar --state done
|
|
22
26
|
apx task list --project iacrmar --tag urgent
|
|
@@ -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
|
-
|
|
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
|
|
package/src/core/stores/tasks.js
CHANGED
|
@@ -167,6 +167,20 @@ export function createTask(storagePath, fields) {
|
|
|
167
167
|
return getTask(storagePath, id);
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Newest first, with `id` as a tiebreak.
|
|
172
|
+
*
|
|
173
|
+
* The tiebreak is not cosmetic: nowIso() strips milliseconds, so every task
|
|
174
|
+
* created within the same SECOND shares a created_at — which is the norm when a
|
|
175
|
+
* routine files several at once. Without a second key the order of those rows
|
|
176
|
+
* is whatever the sort happened to do, and a list that reshuffles between two
|
|
177
|
+
* identical calls is worse than one that is merely arbitrary.
|
|
178
|
+
*/
|
|
179
|
+
function byNewest(a, b) {
|
|
180
|
+
const t = (b.created_at || "").localeCompare(a.created_at || "");
|
|
181
|
+
return t !== 0 ? t : String(b.id || "").localeCompare(String(a.id || ""));
|
|
182
|
+
}
|
|
183
|
+
|
|
170
184
|
/** List tasks with optional filters. */
|
|
171
185
|
export function listTasks(storagePath, opts = {}) {
|
|
172
186
|
const events = readAllEvents(storagePath);
|
|
@@ -190,13 +204,68 @@ export function listTasks(storagePath, opts = {}) {
|
|
|
190
204
|
if (opts.due_after) {
|
|
191
205
|
out = out.filter((t) => t.due && t.due >= opts.due_after);
|
|
192
206
|
}
|
|
193
|
-
|
|
207
|
+
// Workflow sub-status of an OPEN task (pending/running/in_review/blocked).
|
|
208
|
+
// Orthogonal to `state` — "what is blocked right now" is a different question
|
|
209
|
+
// from "what is open".
|
|
210
|
+
if (opts.status) {
|
|
211
|
+
out = out.filter((t) => t.status === opts.status);
|
|
212
|
+
}
|
|
213
|
+
// Everything touched since a moment. The cheapest way to ask "what moved?".
|
|
214
|
+
if (opts.updated_since) {
|
|
215
|
+
out = out.filter((t) => (t.updated_at || t.created_at || "") >= opts.updated_since);
|
|
216
|
+
}
|
|
217
|
+
out.sort(byNewest);
|
|
194
218
|
if (opts.limit && Number.isFinite(opts.limit)) {
|
|
195
219
|
out = out.slice(0, opts.limit);
|
|
196
220
|
}
|
|
197
221
|
return out;
|
|
198
222
|
}
|
|
199
223
|
|
|
224
|
+
/**
|
|
225
|
+
* The same query, folded across every registered project.
|
|
226
|
+
*
|
|
227
|
+
* Lives in core rather than in the daemon route because the CLI, the HTTP API
|
|
228
|
+
* and the panel all need it, and AGENTS.md rule 8 puts a shared operation in
|
|
229
|
+
* one home with the surfaces as adapters. The caller supplies the project list
|
|
230
|
+
* so this stays free of daemon and config imports.
|
|
231
|
+
*
|
|
232
|
+
* A project whose task log is unreadable is SKIPPED, not fatal: one corrupt
|
|
233
|
+
* JSONL file must not blank out the cross-project view. Skipped ids are
|
|
234
|
+
* returned so a surface can say so instead of quietly showing less.
|
|
235
|
+
*
|
|
236
|
+
* @param {{id: any, name?: string, path?: string, storagePath: string}[]} projects
|
|
237
|
+
* @param {object} opts Same filters as listTasks, plus `limit` applied AFTER
|
|
238
|
+
* the merge (a per-project limit would silently favour
|
|
239
|
+
* whichever project sorts first).
|
|
240
|
+
* @returns {{ tasks: object[], skipped: {id: any, error: string}[] }}
|
|
241
|
+
*/
|
|
242
|
+
export function listTasksAcrossProjects(projects, opts = {}) {
|
|
243
|
+
const { limit, ...perProject } = opts || {};
|
|
244
|
+
const tasks = [];
|
|
245
|
+
const skipped = [];
|
|
246
|
+
|
|
247
|
+
for (const entry of projects || []) {
|
|
248
|
+
if (!entry?.storagePath) continue;
|
|
249
|
+
try {
|
|
250
|
+
for (const t of listTasks(entry.storagePath, perProject)) {
|
|
251
|
+
tasks.push({
|
|
252
|
+
...t,
|
|
253
|
+
project_id: entry.id,
|
|
254
|
+
project_name: entry.name || entry.path || String(entry.id),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
} catch (e) {
|
|
258
|
+
skipped.push({ id: entry.id, error: e?.message || String(e) });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
tasks.sort(byNewest);
|
|
263
|
+
return {
|
|
264
|
+
tasks: Number.isFinite(limit) && limit > 0 ? tasks.slice(0, limit) : tasks,
|
|
265
|
+
skipped,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
200
269
|
/** Get a single task by id or by id prefix (≥ 3 chars, must be unique). */
|
|
201
270
|
export function getTask(storagePath, idOrPrefix) {
|
|
202
271
|
if (!idOrPrefix || typeof idOrPrefix !== "string") return null;
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
// GET /
|
|
1
|
+
// Tasks (TODOs). Backed by core/stores/tasks.js (JSONL event log).
|
|
2
|
+
// GET /tasks cross-project; same filters, plus ?offset
|
|
3
|
+
// GET /projects/:pid/tasks ?state=open|done|dropped|all&tag=X&agent=Y
|
|
4
|
+
// &due_before=ISO&due_after=ISO&limit=N
|
|
5
|
+
// &status=pending|running|in_review|blocked&updated_since=ISO
|
|
3
6
|
// POST /projects/:pid/tasks { title, body?, tags?, due?, agent?, source?, meta? }
|
|
4
7
|
// GET /projects/:pid/tasks/:id (id or prefix)
|
|
5
8
|
// PATCH /projects/:pid/tasks/:id { patch: {...} }
|
|
@@ -9,6 +12,7 @@
|
|
|
9
12
|
import {
|
|
10
13
|
createTask,
|
|
11
14
|
listTasks,
|
|
15
|
+
listTasksAcrossProjects,
|
|
12
16
|
getTask,
|
|
13
17
|
patchTask,
|
|
14
18
|
doneTask,
|
|
@@ -25,21 +29,36 @@ export function register(app, { project, projects }) {
|
|
|
25
29
|
// envelope. Paginated via ?limit & ?offset; with no limit, data is the full
|
|
26
30
|
// set as one page.
|
|
27
31
|
app.get("/tasks", (req, res) => {
|
|
28
|
-
const state = req.query
|
|
29
|
-
|
|
32
|
+
const { state, tag, agent, due_before, due_after, status, updated_since } = req.query;
|
|
33
|
+
|
|
34
|
+
// Resolve the registered projects to what core needs, dropping any the
|
|
35
|
+
// manager can no longer open.
|
|
36
|
+
const entries = [];
|
|
30
37
|
for (const entry of projects.list()) {
|
|
31
38
|
const p = projects.get(entry.id);
|
|
32
|
-
if (!p) continue;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
for (const t of tasks) out.push({ ...t, project_id: entry.id, project_name: entry.name || entry.path });
|
|
39
|
+
if (!p?.storagePath) continue;
|
|
40
|
+
entries.push({
|
|
41
|
+
id: entry.id,
|
|
42
|
+
name: entry.name || entry.path,
|
|
43
|
+
path: entry.path,
|
|
44
|
+
storagePath: p.storagePath,
|
|
45
|
+
});
|
|
40
46
|
}
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
|
|
48
|
+
const { tasks, skipped } = listTasksAcrossProjects(entries, {
|
|
49
|
+
state: state === "all" ? undefined : (state || "open"),
|
|
50
|
+
tag: tag || undefined,
|
|
51
|
+
agent: agent || undefined,
|
|
52
|
+
due_before: due_before || undefined,
|
|
53
|
+
due_after: due_after || undefined,
|
|
54
|
+
status: status || undefined,
|
|
55
|
+
updated_since: updated_since || undefined,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const envelope = pageEnvelope(tasks, req.query);
|
|
59
|
+
// Say when a project could not be read rather than quietly showing less.
|
|
60
|
+
if (skipped.length) envelope.meta = { ...(envelope.meta || {}), skipped };
|
|
61
|
+
res.json(envelope);
|
|
43
62
|
});
|
|
44
63
|
|
|
45
64
|
// Per-project tasks. Returns a { meta, data } envelope; with no ?limit the
|
|
@@ -47,13 +66,15 @@ export function register(app, { project, projects }) {
|
|
|
47
66
|
app.get("/projects/:pid/tasks", (req, res) => {
|
|
48
67
|
const p = project(req, res);
|
|
49
68
|
if (!p) return;
|
|
50
|
-
const { state, tag, agent, due_before, due_after } = req.query;
|
|
69
|
+
const { state, tag, agent, due_before, due_after, status, updated_since } = req.query;
|
|
51
70
|
const all = listTasks(p.storagePath, {
|
|
52
71
|
state: state || undefined,
|
|
53
72
|
tag: tag || undefined,
|
|
54
73
|
agent: agent || undefined,
|
|
55
74
|
due_before: due_before || undefined,
|
|
56
75
|
due_after: due_after || undefined,
|
|
76
|
+
status: status || undefined,
|
|
77
|
+
updated_since: updated_since || undefined,
|
|
57
78
|
});
|
|
58
79
|
res.json(pageEnvelope(all, req.query));
|
|
59
80
|
});
|
|
@@ -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
|
|
package/src/host/daemon/api.js
CHANGED
|
@@ -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
|