agentix-cli 0.27.0 → 0.29.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/README.md +93 -118
- package/apps/mac-helper/Sources/AgentXHelper/AXTree.swift +159 -0
- package/apps/mac-helper/Sources/AgentXHelper/Brand.swift +63 -0
- package/apps/mac-helper/Sources/AgentXHelper/Focus.swift +97 -0
- package/apps/mac-helper/Sources/AgentXHelper/HUD.swift +179 -0
- package/apps/mac-helper/Sources/AgentXHelper/OCR.swift +117 -0
- package/apps/mac-helper/Sources/AgentXHelper/Pointer.swift +309 -0
- package/apps/mac-helper/Sources/AgentXHelper/Screens.swift +67 -0
- package/apps/mac-helper/Sources/AgentXHelper/Typer.swift +185 -0
- package/apps/mac-helper/Sources/AgentXHelper/Vision.swift +201 -0
- package/apps/mac-helper/Sources/AgentXHelper/main.swift +265 -0
- package/apps/mac-helper/build.sh +39 -0
- package/apps/mac-voice/Resources/Info.plist +18 -0
- package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +108 -0
- package/apps/mac-voice/Sources/AgentXVoice/Brand.swift +123 -0
- package/apps/mac-voice/Sources/AgentXVoice/Config.swift +68 -0
- package/apps/mac-voice/Sources/AgentXVoice/Hold.swift +74 -0
- package/apps/mac-voice/Sources/AgentXVoice/Hotkey.swift +76 -0
- package/apps/mac-voice/Sources/AgentXVoice/Markdown.swift +140 -0
- package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +275 -0
- package/apps/mac-voice/Sources/AgentXVoice/Progress.swift +147 -0
- package/apps/mac-voice/Sources/AgentXVoice/Recorder.swift +139 -0
- package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +162 -0
- package/apps/mac-voice/Sources/AgentXVoice/SmartPaste.swift +72 -0
- package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +192 -0
- package/apps/mac-voice/Sources/AgentXVoice/StepLabel.swift +159 -0
- package/apps/mac-voice/Sources/AgentXVoice/main.swift +380 -0
- package/apps/mac-voice/build.sh +32 -0
- package/dist/{App-U52IPUK2.js → App-IEQXWJ7B.js} +2 -2
- package/dist/{ChatApp-XP4OMHPL.js → ChatApp-NWOSAP5U.js} +2 -2
- package/dist/{agent-V2CA4TXD.js → agent-LS4CZPJ6.js} +2 -2
- package/dist/{board-dashboard-TTMAYRLG.js → board-dashboard-6QQCKFB3.js} +8 -10
- package/dist/board-dashboard-6QQCKFB3.js.map +1 -0
- package/dist/{chunk-DCDQZR7I.js → chunk-3QVOWDHZ.js} +6 -6
- package/dist/chunk-3QVOWDHZ.js.map +1 -0
- package/dist/{chunk-FIEJDZVC.js → chunk-4KXBUORV.js} +2 -2
- package/dist/{chunk-UFAGYX73.js → chunk-6F2IKJ2F.js} +8 -8
- package/dist/chunk-6F2IKJ2F.js.map +1 -0
- package/dist/chunk-AE2LDMZU.js +2 -0
- package/dist/chunk-AE2LDMZU.js.map +1 -0
- package/dist/{chunk-VTE7L6LO.js → chunk-DI7FMCDK.js} +2 -2
- package/dist/{chunk-AX2PWSRT.js → chunk-F5AEXRPR.js} +2 -2
- package/dist/{chunk-CJQQIRPD.js → chunk-GMNKJXTR.js} +7 -7
- package/dist/chunk-GMNKJXTR.js.map +1 -0
- package/dist/{chunk-L2IZPR6P.js → chunk-JIOLJIBM.js} +2 -2
- package/dist/{chunk-IB3F7VBE.js → chunk-JKYMXLYL.js} +3 -3
- package/dist/chunk-JKYMXLYL.js.map +1 -0
- package/dist/{chunk-LNRB5MCU.js → chunk-LNWX4MPX.js} +151 -134
- package/dist/chunk-LNWX4MPX.js.map +1 -0
- package/dist/{chunk-WZ72HX5C.js → chunk-OTXDKKEU.js} +145 -138
- package/dist/chunk-OTXDKKEU.js.map +1 -0
- package/dist/{chunk-IBUSGCLQ.js → chunk-SBCZLZG3.js} +2 -2
- package/dist/{chunk-2T5W3ETS.js → chunk-SC5DIKBB.js} +2 -2
- package/dist/{chunk-BPIRO4YD.js → chunk-UYSNGBDT.js} +2 -2
- package/dist/chunk-XPXO5K4L.js +164 -0
- package/dist/chunk-XPXO5K4L.js.map +1 -0
- package/dist/{chunk-GDWJUGB5.js → chunk-Z2Y24ETP.js} +2 -2
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/client-5Z3URGPY.js +2 -0
- package/dist/config-ORSGLVRW.js +2 -0
- package/dist/config-mutator-JB3S2E7H.js +2 -0
- package/dist/doctor-FS2BX6H4.js +2 -0
- package/dist/index.d.ts +63 -42
- package/dist/index.js +12 -19
- package/dist/index.js.map +1 -1
- package/dist/program-I4J63BQ7.js +2 -0
- package/dist/query-R7EI3VGX.js +2 -0
- package/dist/registry-UJIJBLUR.js +2 -0
- package/dist/request-planner-BKT6RZKX.js +2 -0
- package/dist/request-planner-BKT6RZKX.js.map +1 -0
- package/dist/{review-candidates-AEUXRQQC.js → review-candidates-F3OFYTCZ.js} +2 -2
- package/dist/routing-S5WSFIAD.js +2 -0
- package/dist/scheduler-DSAOMV4O.js +2 -0
- package/dist/{seat-VZ7AVO27.js → seat-ZVHXNAEN.js} +2 -2
- package/dist/{select-F5I5PO2O.js → select-L7C52URW.js} +2 -2
- package/dist/wiki-5YQI4YKM.js +2 -0
- package/dist/wiki-5YQI4YKM.js.map +1 -0
- package/package.json +14 -3
- package/scripts/postinstall.mjs +80 -0
- package/dist/board-dashboard-TTMAYRLG.js.map +0 -1
- package/dist/chunk-CJQQIRPD.js.map +0 -1
- package/dist/chunk-DCDQZR7I.js.map +0 -1
- package/dist/chunk-IB3F7VBE.js.map +0 -1
- package/dist/chunk-LNRB5MCU.js.map +0 -1
- package/dist/chunk-OPHIXXGI.js +0 -157
- package/dist/chunk-OPHIXXGI.js.map +0 -1
- package/dist/chunk-UFAGYX73.js.map +0 -1
- package/dist/chunk-WZ72HX5C.js.map +0 -1
- package/dist/client-PX25MD47.js +0 -2
- package/dist/config-HL5F36V4.js +0 -2
- package/dist/config-mutator-HBX53OMJ.js +0 -2
- package/dist/doctor-PBAH6XL3.js +0 -2
- package/dist/program-YGXVCV2Y.js +0 -2
- package/dist/query-ZKTFJMNE.js +0 -2
- package/dist/registry-PSZDPFZK.js +0 -2
- package/dist/routing-4PV66BK6.js +0 -2
- package/dist/routing-4PV66BK6.js.map +0 -1
- package/dist/scheduler-GZZAN6ZG.js +0 -2
- package/dist/wiki-N5SMP3DT.js +0 -2
- /package/dist/{App-U52IPUK2.js.map → App-IEQXWJ7B.js.map} +0 -0
- /package/dist/{ChatApp-XP4OMHPL.js.map → ChatApp-NWOSAP5U.js.map} +0 -0
- /package/dist/{agent-V2CA4TXD.js.map → agent-LS4CZPJ6.js.map} +0 -0
- /package/dist/{chunk-FIEJDZVC.js.map → chunk-4KXBUORV.js.map} +0 -0
- /package/dist/{chunk-VTE7L6LO.js.map → chunk-DI7FMCDK.js.map} +0 -0
- /package/dist/{chunk-AX2PWSRT.js.map → chunk-F5AEXRPR.js.map} +0 -0
- /package/dist/{chunk-L2IZPR6P.js.map → chunk-JIOLJIBM.js.map} +0 -0
- /package/dist/{chunk-IBUSGCLQ.js.map → chunk-SBCZLZG3.js.map} +0 -0
- /package/dist/{chunk-2T5W3ETS.js.map → chunk-SC5DIKBB.js.map} +0 -0
- /package/dist/{chunk-BPIRO4YD.js.map → chunk-UYSNGBDT.js.map} +0 -0
- /package/dist/{chunk-GDWJUGB5.js.map → chunk-Z2Y24ETP.js.map} +0 -0
- /package/dist/{client-PX25MD47.js.map → client-5Z3URGPY.js.map} +0 -0
- /package/dist/{config-HL5F36V4.js.map → config-ORSGLVRW.js.map} +0 -0
- /package/dist/{config-mutator-HBX53OMJ.js.map → config-mutator-JB3S2E7H.js.map} +0 -0
- /package/dist/{doctor-PBAH6XL3.js.map → doctor-FS2BX6H4.js.map} +0 -0
- /package/dist/{program-YGXVCV2Y.js.map → program-I4J63BQ7.js.map} +0 -0
- /package/dist/{query-ZKTFJMNE.js.map → query-R7EI3VGX.js.map} +0 -0
- /package/dist/{registry-PSZDPFZK.js.map → registry-UJIJBLUR.js.map} +0 -0
- /package/dist/{review-candidates-AEUXRQQC.js.map → review-candidates-F3OFYTCZ.js.map} +0 -0
- /package/dist/{scheduler-GZZAN6ZG.js.map → routing-S5WSFIAD.js.map} +0 -0
- /package/dist/{seat-VZ7AVO27.js.map → scheduler-DSAOMV4O.js.map} +0 -0
- /package/dist/{wiki-N5SMP3DT.js.map → seat-ZVHXNAEN.js.map} +0 -0
- /package/dist/{select-F5I5PO2O.js.map → select-L7C52URW.js.map} +0 -0
package/README.md
CHANGED
|
@@ -1,157 +1,132 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
1
|
<div align="center">
|
|
4
2
|
|
|
5
|
-
<img src="docs/public/
|
|
3
|
+
<img src="docs/public/agentx-symbol.png" alt="AgentX" width="96" />
|
|
6
4
|
|
|
7
5
|
# AgentX
|
|
8
6
|
|
|
9
|
-
**
|
|
7
|
+
**Put an AI teammate on the tools your team already uses.**
|
|
8
|
+
|
|
9
|
+
<a href="https://www.producthunt.com/products/agentx-4?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-agentx-5" target="_blank" rel="noopener noreferrer"><img alt="AgentX - AI teammates in the tools your team already uses | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1258056&theme=light&t=1790073592268" /></a>
|
|
10
10
|
|
|
11
11
|
[](https://www.npmjs.com/package/agentix-cli)
|
|
12
12
|
[](https://github.com/anis-marrouchi/agentx/actions/workflows/ci.yml)
|
|
13
13
|
[](LICENSE)
|
|
14
14
|
[](package.json)
|
|
15
|
-
[](https://github.com/a2aproject/A2A)
|
|
16
15
|
|
|
17
|
-
[
|
|
16
|
+
[See the demo](#see-it-without-an-account) · [Install](#install) · [Read the guide](docs/index.md)
|
|
17
|
+
|
|
18
|
+
</div>
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
<img src="docs/public/screenshots/mesh.png" alt="Live mesh graph — agents on different machines delegating over A2A" width="820" />
|
|
20
|
+
AgentX routes messages and scheduled jobs to AI agents you host. Connect Telegram, WhatsApp, GitLab, GitHub, or a webhook. Give each agent a clear job. Watch the result in a browser dashboard.
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
## Current state: usable, experimental, not yet stable
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
AgentX works and can be used today, but it is still an **experimental project, not a stable release**. There is plenty to polish and fix, especially the **Settings experience and configuration flows**. Expect rough edges and changes as people try it in real environments.
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
The current source release is **0.28.0**. Our priority is to learn from actual use: what works, what breaks, and what is confusing to set up or operate.
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
**Still missing: role-based access control (RBAC) at both mesh and agent levels.** We want to define who can access shared nodes and agents, delegate work, and perform particular actions. This would open up more possibilities for shared teams and multi-user deployments. Existing mesh authentication and tool permissions do not provide this complete role model; RBAC is planned work, not an available feature.
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
You (Telegram): "@cx CI is red on gitlab — fix it and ship a patch"
|
|
32
|
-
cx (laptop) → resolves @builder via the mesh directory
|
|
33
|
-
builder (vps) → fixes, opens the MR, pipeline green
|
|
34
|
-
cx → replies in your Telegram thread with the MR link
|
|
35
|
-
```
|
|
30
|
+
[Try the demo](#see-it-without-an-account), [check the requirements](docs/requirements.md), or [help us test](#help-test-and-improve-agentx).
|
|
36
31
|
|
|
37
|
-
|
|
32
|
+

|
|
38
33
|
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
*The Monitor dashboard in the isolated demo: review tasks that need you, with in-page agent chat. All displayed tasks are fictional.*
|
|
35
|
+
|
|
36
|
+
## See it without an account
|
|
37
|
+
|
|
38
|
+
From a built source checkout with Node.js 22.x, run:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
node dist/cli.js demo
|
|
41
42
|
```
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
The demo starts three local AgentX daemons and sends a task between them. Routing, network calls, and the event record are real. Model responses are scripted, so the demo makes no billable model calls. The terminal prints the dashboard addresses. [What to expect](docs/see-it-first.md).
|
|
45
|
+
|
|
46
|
+
## What it does
|
|
47
|
+
|
|
48
|
+
- **Answers where your team works.** Connect a channel and assign its messages to an agent.
|
|
49
|
+
- **Runs scheduled work.** Ask an agent for a daily report or another recurring task.
|
|
50
|
+
- **Shows what happened.** Live shows current work; Activity records completed runs; Monitor shows reviewed work that needs a person and work agents can handle.
|
|
51
|
+
- **Offers several ways to interact.** Use [in-page chat](docs/dashboard/chat.md), the [OpenCode-backed TUI](docs/dashboard/tui.md), or the desktop assistant for voice and computer use.
|
|
52
|
+
- **Grows across machines.** Start with one host, then pair another when a job needs its tools or files.
|
|
53
|
+
|
|
54
|
+
## Describe an automation, get an automation
|
|
55
|
+
|
|
56
|
+
The Workflows editor has a chat control. Describe the timing, source, and destination in ordinary language. An authoring agent can propose a workflow and show an **Apply to canvas** action. Read the proposed steps before applying them: this replaces the current graph. Real authoring needs a working model. The demo includes a fixed example for **Build the demo report workflow**. [Walk through it](docs/automations/describe-it.md).
|
|
57
|
+
|
|
58
|
+

|
|
44
59
|
|
|
45
60
|
## Install
|
|
46
61
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
A technical teammate needs a terminal for installation, model setup, and starting the services. The web wizard handles the rest of the initial setup; daily inspection is in the browser. AgentX requires **Node.js 22.x**.
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
git clone https://github.com/anis-marrouchi/agentx.git
|
|
66
|
+
cd agentx
|
|
67
|
+
git checkout main
|
|
68
|
+
cp .env.example .env
|
|
69
|
+
docker compose up --build -d
|
|
50
70
|
```
|
|
51
71
|
|
|
52
|
-
|
|
72
|
+
Open **http://127.0.0.1:4202/setup**. Compose builds this checkout and starts both services; its default image uses an API model rather than a preinstalled model CLI.
|
|
73
|
+
|
|
74
|
+
Without Docker, install Node 22.x and pnpm 10, then build and open the wizard:
|
|
53
75
|
|
|
54
|
-
```
|
|
55
|
-
|
|
76
|
+
```sh
|
|
77
|
+
pnpm install
|
|
78
|
+
pnpm build
|
|
79
|
+
node dist/cli.js setup
|
|
56
80
|
```
|
|
57
81
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
- **One agent can't be everywhere.** Your GitLab runner box, your WhatsApp-paired Mac, and your GPU server each run the agents that belong there; the mesh routes `@mentions` to whichever node owns the agent — cross-machine, cross-channel.
|
|
65
|
-
- **A2A-native, not proprietary.** Nodes exchange agent cards and tasks over the Agent2Agent protocol (150+ orgs behind the standard). Your mesh isn't locked to AgentX — anything that speaks A2A can join the conversation.
|
|
66
|
-
- **Auditable by construction.** Every dispatch decision — channel message, mesh hop, cron fire, workflow step — lands in an append-only intent ledger. `agentx ledger replay` reproduces the decision deterministically when you need to know *why* an agent did something.
|
|
67
|
-
- **Secure by default.** The daemon binds to loopback; mesh endpoints verify bearer tokens; agent-to-agent chains are depth-capped; credentials live in env vars and never enter prompts. Threat model: [SECURITY.md](SECURITY.md).
|
|
68
|
-
|
|
69
|
-
## What you get
|
|
70
|
-
|
|
71
|
-
- **Channels** — Telegram, WhatsApp (QR pair in the browser), GitLab, GitHub, generic webhooks. Agents reply where they were asked, or push anywhere.
|
|
72
|
-
- **Mesh federation** — pair nodes over Tailscale/VPN with one link; manage any peer's config from one dashboard; GitLab mentions route across nodes with peer-owned tokens.
|
|
73
|
-
- **Agents are folders, not code** — persona, knowledge, and tools in plain Markdown (`CLAUDE.md`, skills, references). Bring Claude Code, Codex CLI, OpenCode with any configured provider, Anthropic/OpenAI APIs, or an OpenAI-compatible endpoint.
|
|
74
|
-
- **Operator surface** — browser setup wizard + 11-page admin dashboard: live activity, Kanban boards synced two-way with GitLab/GitHub issues, token-cost accounting, scoped API tokens.
|
|
75
|
-
- **Scheduled work** — plain-English cron: `agentx schedule "every Monday at 9am" --agent sales`, with failure alerts and auto-disable.
|
|
76
|
-
- **Workflows & procedures** — declarative YAML state machines with a visual editor for the flows that must survive restarts; versioned SOPs agents cite at runtime; deterministic no-LLM handlers for canonical answers.
|
|
77
|
-
- **Compounding memory** — conversations absorb into a shared wiki (typed articles, `[[wikilinks]]`, versioned) that every agent queries — also exposed to Cursor/Claude Code via MCP (`agentx serve --stdio`).
|
|
78
|
-
- **Wearable agents** — `agentx attach <agent>` makes the Claude Code session you already have open answer as that agent, so a Telegram or GitLab message reaches you where you're already working instead of spawning a subprocess. Unclaimed messages fall back to a spawned agent, so it's a preference, never a dependency.
|
|
79
|
-
- **Governance** — intent ledger + replay, PM gating, typed capabilities, delegation-depth caps, destructive-action guardrails that hold even under `bypassPermissions`.
|
|
80
|
-
|
|
81
|
-
## How it compares
|
|
82
|
-
|
|
83
|
-
| | **AgentX** | OpenClaw | Hermes Agent | Claude Code |
|
|
84
|
-
|---|---|---|---|---|
|
|
85
|
-
| Agents per install | a mesh of many, across machines | one | one | one session |
|
|
86
|
-
| Cross-machine delegation | ✅ A2A protocol | ❌ | ❌ | ❌ (in-process subagents) |
|
|
87
|
-
| Open interop standard | ✅ A2A (Linux Foundation) | ❌ | ❌ | MCP for tools |
|
|
88
|
-
| Users / operators | team, multi-operator | single-user | single-user | single account |
|
|
89
|
-
| Audit ledger + replay | ✅ append-only, deterministic replay | ❌ | ❌ | ❌ |
|
|
90
|
-
| GitLab-native (boards, MRs, cross-node mentions) | ✅ | ❌ | ❌ | ❌ |
|
|
91
|
-
| Chat channels | 6 + webhooks + plugins | ~24 | multiple | terminal |
|
|
92
|
-
| LLM providers | Claude Code, Codex CLI, OpenCode, Anthropic, OpenAI-compatible | Claude-first | model-agnostic | Claude |
|
|
93
|
-
| Self-hosted, no telemetry | ✅ | ✅ | ✅ | n/a |
|
|
94
|
-
|
|
95
|
-
*(As of July 2026 — corrections welcome via PR.)*
|
|
96
|
-
|
|
97
|
-
> **Running solo, just for yourself?** [OpenClaw](https://github.com/openclaw/openclaw) is built for exactly that and has a lighter install. If you outgrow it, we import your config: [Migrate from OpenClaw](docs/migration/from-openclaw.md).
|
|
98
|
-
|
|
99
|
-
## Use cases
|
|
100
|
-
|
|
101
|
-
- **Solo founder ops** — run support triage, invoices, and scheduled reports from Telegram ([journey ch. 1–2](docs/journey/01-telegram-qa-bot.md))
|
|
102
|
-
- **Dev team** — GitLab MR review agent + WhatsApp escalation + nightly maintenance across two machines ([mesh federation](docs/journey/08-mesh-federation.md))
|
|
103
|
-
- **Agency / B2B** — one node per client, scoped tokens, per-agent cost accounting ([tokens](docs/reference/tokens.md), [usage](docs/playbooks/tier2-billing.md))
|
|
104
|
-
- **Public-facing service** — citizen/customer intake bot with an auditable trail of every decision ([BPM grant application](docs/journey/12-bpm-grant-application.md))
|
|
105
|
-
|
|
106
|
-
## Screenshots
|
|
107
|
-
|
|
108
|
-
| Live activity | Mesh | Boards |
|
|
109
|
-
|---|---|---|
|
|
110
|
-
|  |  |  |
|
|
111
|
-
|
|
112
|
-
| Admin | Intent graph | Setup wizard |
|
|
113
|
-
|---|---|---|
|
|
114
|
-
|  |  |  |
|
|
115
|
-
|
|
116
|
-
## Architecture
|
|
117
|
-
|
|
118
|
-
```mermaid
|
|
119
|
-
graph LR
|
|
120
|
-
T[Telegram] --> R(Router)
|
|
121
|
-
W[WhatsApp] --> R
|
|
122
|
-
G[GitLab] --> R
|
|
123
|
-
GH[GitHub] --> R
|
|
124
|
-
C[Cron] --> R
|
|
125
|
-
H[Webhook] --> R
|
|
126
|
-
M[Mesh peer · A2A] --> R
|
|
127
|
-
PL[Plugin channels] --> R
|
|
128
|
-
R --> LDG[(Intent Ledger<br/>append-only)]
|
|
129
|
-
LDG --> CTX[Context Engine]
|
|
130
|
-
CTX --> AG[Agent workspace]
|
|
131
|
-
AG --> P1[Claude Code]
|
|
132
|
-
AG --> P2[Codex CLI / OpenCode]
|
|
133
|
-
AG --> P3[Anthropic / OpenAI-compatible API]
|
|
134
|
-
AG -.-> MEM[(Wiki + Graph + Memory)]
|
|
135
|
-
AG -.-> WF[Workflows + Procedures]
|
|
136
|
-
AG -.-> R
|
|
82
|
+
On a source install, the wizard serves the dashboard and **the daemon is a separate process**. Start it in another terminal, or use the wizard's **Start daemon now** button:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
node dist/cli.js daemon start --detach
|
|
86
|
+
node dist/cli.js daemon status
|
|
137
87
|
```
|
|
138
88
|
|
|
139
|
-
|
|
89
|
+
The npm package is `agentix-cli`; the installed executable is `agentx`. **Published version 0.27.0 has an installation error:** its postinstall script is missing from the package. Version 0.28.0 includes the fix; use 0.28.0 or newer, or build from source/Docker. Follow the [installation guide](docs/install.md) for details.
|
|
90
|
+
|
|
91
|
+
## One machine is enough to start
|
|
140
92
|
|
|
141
|
-
|
|
93
|
+
When an agent needs to work on another machine, pair the nodes with `agentx connect mesh invite` and `agentx connect mesh join <link>`. A connected agent can pass work to another node. For example:
|
|
142
94
|
|
|
143
|
-
|
|
144
|
-
|
|
95
|
+
```text
|
|
96
|
+
You: "@cx CI is red on GitLab — find the cause"
|
|
97
|
+
CX: sends the build investigation to @builder on another node
|
|
98
|
+
Builder: checks the project and reports the result
|
|
99
|
+
CX: replies in the original thread
|
|
100
|
+
```
|
|
145
101
|
|
|
146
|
-
|
|
102
|
+
The [second-machine guide](docs/jobs/second-machine.md) covers pairing. You do not need mesh for a one-agent setup.
|
|
147
103
|
|
|
148
|
-
##
|
|
104
|
+
## The dashboard
|
|
149
105
|
|
|
150
|
-
- **[
|
|
151
|
-
|
|
106
|
+
The six top-level tabs are **Live, Operations, Monitor, Activity, Workflows, and Settings**. [Dashboard guide](docs/dashboard/index.md). The dashboard and daemon must both be running for messages to be handled.
|
|
107
|
+
|
|
108
|
+
## Channels and models
|
|
109
|
+
|
|
110
|
+
Telegram and WhatsApp have pairing flows. GitLab, GitHub, and generic webhooks can bring in events. **Slack and Discord are not supported as live channel adapters in this build.** [Channel reference](docs/reference/channels.md).
|
|
152
111
|
|
|
153
|
-
|
|
112
|
+
Real agents need a configured model. An API provider needs a key; Claude Code, Codex CLI, and OpenCode need their CLI installed and authenticated on the host. Model-provider charges or subscription terms depend on your chosen provider.
|
|
154
113
|
|
|
155
|
-
##
|
|
114
|
+
## Help test and improve AgentX
|
|
115
|
+
|
|
116
|
+
The most valuable contribution right now is **trying AgentX and reporting what happens**. You do not need to write code to help.
|
|
117
|
+
|
|
118
|
+
1. **Test and report issues.** Try installation, Settings, model setup, chat, the desktop assistant, the TUI, or a task across nodes. Report failures and confusing steps, including what you expected and what actually happened.
|
|
119
|
+
2. **Help with triage and workflow/pipeline management.** Reproduce reports, identify duplicates, clarify priorities, and help follow issues through fixes, CI, and releases.
|
|
120
|
+
3. **Contribute code and documentation.** Fix bugs, improve setup and Settings, clarify instructions, or help design the missing mesh-level and agent-level RBAC. Discuss larger changes in an issue first.
|
|
121
|
+
|
|
122
|
+
[Report an issue](https://github.com/anis-marrouchi/agentx/issues/new) with your version, operating system, steps to reproduce, and relevant logs or screenshots. Remove secrets and private data before sharing. Report security vulnerabilities through the [security policy](SECURITY.md).
|
|
123
|
+
|
|
124
|
+
See [Contributing](CONTRIBUTING.md) for testing, issue triage, and development instructions.
|
|
125
|
+
|
|
126
|
+
## Help and development
|
|
127
|
+
|
|
128
|
+
- [Start here](docs/index.md) · [Troubleshooting](docs/help/its-not-answering.md) · [CLI reference](docs/reference/cli.md)
|
|
129
|
+
- [Support and supported environments](.github/SUPPORT.md) · [Ask a question](https://github.com/anis-marrouchi/agentx/discussions)
|
|
130
|
+
- [Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md) · [Changelog](CHANGELOG.md)
|
|
156
131
|
|
|
157
|
-
MIT.
|
|
132
|
+
AgentX is released under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import ApplicationServices
|
|
3
|
+
|
|
4
|
+
/// Reads the focused application's accessibility tree.
|
|
5
|
+
///
|
|
6
|
+
/// The tree, not a screenshot, is the primary surface. It gives structured,
|
|
7
|
+
/// verifiable elements with real roles and real frames — so a click can be
|
|
8
|
+
/// confirmed to have hit the control that was chosen, and the candidate set
|
|
9
|
+
/// is ENUMERABLE, which is what lets a decision model pick from it rather
|
|
10
|
+
/// than invent coordinates.
|
|
11
|
+
///
|
|
12
|
+
/// Its honest failure mode is apps that expose nothing: icon-only toolbars,
|
|
13
|
+
/// custom-drawn editors, canvas games. For those the tree comes back thin
|
|
14
|
+
/// and the caller should say so rather than guess — screenshots are the
|
|
15
|
+
/// documented fallback, and are deliberately not in this first cut.
|
|
16
|
+
enum AXTree {
|
|
17
|
+
|
|
18
|
+
struct Element: Codable {
|
|
19
|
+
let id: Int
|
|
20
|
+
let role: String
|
|
21
|
+
/// Best human-readable name, from whichever attribute has one.
|
|
22
|
+
let label: String
|
|
23
|
+
let value: String?
|
|
24
|
+
let enabled: Bool
|
|
25
|
+
let x: Double, y: Double, width: Double, height: Double
|
|
26
|
+
/// Index of the parent in the same flat array; -1 for the root.
|
|
27
|
+
let parent: Int
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
struct Snapshot: Codable {
|
|
31
|
+
let app: String
|
|
32
|
+
let pid: Int32
|
|
33
|
+
let window: String?
|
|
34
|
+
let elements: [Element]
|
|
35
|
+
let truncated: Bool
|
|
36
|
+
let note: String?
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Accessibility is TCC-gated and there is no way to ask politely from
|
|
40
|
+
/// a CLI — the prompt only appears for a bundled, signed app. Report it
|
|
41
|
+
/// clearly instead of returning an empty tree that looks like an app
|
|
42
|
+
/// with no controls.
|
|
43
|
+
static func trusted() -> Bool { AXIsProcessTrusted() }
|
|
44
|
+
|
|
45
|
+
static func snapshot(maxElements: Int = 400) -> Snapshot {
|
|
46
|
+
guard let app = NSWorkspace.shared.frontmostApplication else {
|
|
47
|
+
return Snapshot(app: "unknown", pid: 0, window: nil, elements: [],
|
|
48
|
+
truncated: false, note: "no frontmost application")
|
|
49
|
+
}
|
|
50
|
+
let axApp = AXUIElementCreateApplication(app.processIdentifier)
|
|
51
|
+
var elements: [Element] = []
|
|
52
|
+
var truncated = false
|
|
53
|
+
|
|
54
|
+
// The focused window, not the whole app: an app's tree includes
|
|
55
|
+
// every window it owns, and pointing at a control in a background
|
|
56
|
+
// window is worse than useless.
|
|
57
|
+
var windowRef: CFTypeRef?
|
|
58
|
+
AXUIElementCopyAttributeValue(axApp, kAXFocusedWindowAttribute as CFString, &windowRef)
|
|
59
|
+
let root = (windowRef as! AXUIElement?) ?? axApp
|
|
60
|
+
let windowTitle = string(root, kAXTitleAttribute)
|
|
61
|
+
|
|
62
|
+
// Traverse everything, but only SPEND the budget on elements worth
|
|
63
|
+
// choosing between.
|
|
64
|
+
//
|
|
65
|
+
// A modern web page is mostly containers: x.com fills 273 of 400
|
|
66
|
+
// slots with unlabelled AXGroups before reaching its search field,
|
|
67
|
+
// so a naive cap returns a tree with no inputs in it and the page
|
|
68
|
+
// looks like it exposes nothing. Skipped nodes still have their
|
|
69
|
+
// children walked; `parent` points at the nearest KEPT ancestor, so
|
|
70
|
+
// the hierarchy stays usable for page-scoping.
|
|
71
|
+
var queue: [(AXUIElement, Int)] = [(root, -1)]
|
|
72
|
+
while !queue.isEmpty {
|
|
73
|
+
if elements.count >= maxElements { truncated = true; break }
|
|
74
|
+
let (node, parent) = queue.removeFirst()
|
|
75
|
+
|
|
76
|
+
var keptIndex = parent
|
|
77
|
+
if let e = describe(node, id: elements.count, parent: parent), worthKeeping(e) {
|
|
78
|
+
keptIndex = elements.count
|
|
79
|
+
elements.append(e)
|
|
80
|
+
}
|
|
81
|
+
var childrenRef: CFTypeRef?
|
|
82
|
+
if AXUIElementCopyAttributeValue(node, kAXChildrenAttribute as CFString, &childrenRef) == .success,
|
|
83
|
+
let children = childrenRef as? [AXUIElement] {
|
|
84
|
+
for c in children { queue.append((c, keptIndex)) }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return Snapshot(
|
|
89
|
+
app: app.localizedName ?? "unknown",
|
|
90
|
+
pid: app.processIdentifier,
|
|
91
|
+
window: windowTitle,
|
|
92
|
+
elements: elements,
|
|
93
|
+
truncated: truncated,
|
|
94
|
+
note: elements.count < 3
|
|
95
|
+
? "this app exposes almost no accessibility information — the tree is not usable here"
|
|
96
|
+
: nil)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Containers earn a slot only when they say something.
|
|
100
|
+
///
|
|
101
|
+
/// The web area itself is always kept — page-scoping needs it as an
|
|
102
|
+
/// anchor even though it carries no label of its own.
|
|
103
|
+
private static func worthKeeping(_ e: Element) -> Bool {
|
|
104
|
+
if e.role == "AXWebArea" { return true }
|
|
105
|
+
let isContainer = e.role == "AXGroup" || e.role == "AXUnknown"
|
|
106
|
+
|| e.role == "AXSplitGroup" || e.role == "AXScrollArea"
|
|
107
|
+
if !isContainer { return true }
|
|
108
|
+
// A labelled group is usually a landmark worth naming; "0" and ""
|
|
109
|
+
// are the DOM showing through.
|
|
110
|
+
let label = e.label.trimmingCharacters(in: .whitespaces)
|
|
111
|
+
return label.count > 1 && label != "0"
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private static func describe(_ node: AXUIElement, id: Int, parent: Int) -> Element? {
|
|
115
|
+
guard let role = string(node, kAXRoleAttribute) else { return nil }
|
|
116
|
+
|
|
117
|
+
// First attribute that actually names the thing. Title is usually
|
|
118
|
+
// best, but buttons frequently carry only a description, and menu
|
|
119
|
+
// items only a value.
|
|
120
|
+
let label = [kAXTitleAttribute, kAXDescriptionAttribute, kAXLabelValueAttribute,
|
|
121
|
+
kAXHelpAttribute, kAXPlaceholderValueAttribute]
|
|
122
|
+
.compactMap { string(node, $0) }
|
|
123
|
+
.first(where: { !$0.isEmpty }) ?? ""
|
|
124
|
+
|
|
125
|
+
var frame = CGRect.zero
|
|
126
|
+
var posRef: CFTypeRef?, sizeRef: CFTypeRef?
|
|
127
|
+
if AXUIElementCopyAttributeValue(node, kAXPositionAttribute as CFString, &posRef) == .success,
|
|
128
|
+
AXUIElementCopyAttributeValue(node, kAXSizeAttribute as CFString, &sizeRef) == .success {
|
|
129
|
+
var p = CGPoint.zero, s = CGSize.zero
|
|
130
|
+
AXValueGetValue(posRef as! AXValue, .cgPoint, &p)
|
|
131
|
+
AXValueGetValue(sizeRef as! AXValue, .cgSize, &s)
|
|
132
|
+
frame = CGRect(origin: p, size: s)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
var enabledRef: CFTypeRef?
|
|
136
|
+
AXUIElementCopyAttributeValue(node, kAXEnabledAttribute as CFString, &enabledRef)
|
|
137
|
+
let enabled = (enabledRef as? Bool) ?? true
|
|
138
|
+
|
|
139
|
+
// Invisible and zero-sized nodes cannot be pointed at, and there
|
|
140
|
+
// are a great many of them — dropping them keeps the candidate set
|
|
141
|
+
// small enough to hand to a model.
|
|
142
|
+
if frame.width < 1 || frame.height < 1 { return nil }
|
|
143
|
+
|
|
144
|
+
return Element(id: id, role: role, label: label,
|
|
145
|
+
value: string(node, kAXValueAttribute),
|
|
146
|
+
enabled: enabled,
|
|
147
|
+
x: frame.origin.x, y: frame.origin.y,
|
|
148
|
+
width: frame.size.width, height: frame.size.height,
|
|
149
|
+
parent: parent)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private static func string(_ node: AXUIElement, _ attr: String) -> String? {
|
|
153
|
+
var ref: CFTypeRef?
|
|
154
|
+
guard AXUIElementCopyAttributeValue(node, attr as CFString, &ref) == .success else { return nil }
|
|
155
|
+
if let s = ref as? String { return s.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
156
|
+
if let n = ref as? NSNumber { return n.stringValue }
|
|
157
|
+
return nil
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
/// Acme design tokens, transcribed for AppKit.
|
|
4
|
+
///
|
|
5
|
+
/// Copied from the design system's `tokens/` rather than eyeballed, so the
|
|
6
|
+
/// widget belongs to the same product as example.com instead of merely
|
|
7
|
+
/// looking tidy. Two rules from that system shape most of what follows:
|
|
8
|
+
///
|
|
9
|
+
/// - Mono is UPPERCASE with 0.06–0.08em tracking, and is reserved for
|
|
10
|
+
/// eyebrows, status and meta. Body stays sentence case.
|
|
11
|
+
/// - No emoji, ever. Texture comes from Unicode punctuation — · → ● ✓ —
|
|
12
|
+
/// and from a single accent colour, not from decoration.
|
|
13
|
+
///
|
|
14
|
+
/// System colours were the obvious default and the wrong one: .systemRed
|
|
15
|
+
/// and .systemGreen are macOS's voice, not Acme's, and they shift under
|
|
16
|
+
/// the user's accent-colour setting.
|
|
17
|
+
enum Brand {
|
|
18
|
+
|
|
19
|
+
// Teal accent and deep blue primary — the two colours the whole
|
|
20
|
+
// identity rests on.
|
|
21
|
+
static let accent = NSColor(srgbRed: 0.078, green: 0.722, blue: 0.651, alpha: 1) // #14b8a6
|
|
22
|
+
static let accentDeep = NSColor(srgbRed: 0.059, green: 0.463, blue: 0.431, alpha: 1) // #0f766e
|
|
23
|
+
static let primary = NSColor(srgbRed: 0.118, green: 0.227, blue: 0.541, alpha: 1) // #1e3a8a
|
|
24
|
+
static let primaryBright = NSColor(srgbRed: 0.114, green: 0.306, blue: 0.847, alpha: 1) // #1d4ed8
|
|
25
|
+
|
|
26
|
+
static let ink = NSColor(srgbRed: 0.043, green: 0.078, blue: 0.075, alpha: 1) // #0b1413
|
|
27
|
+
static let paper = NSColor(srgbRed: 0.980, green: 0.980, blue: 0.976, alpha: 1) // #fafaf9
|
|
28
|
+
|
|
29
|
+
/// Amber and rose for states the palette has no token for. Muted to
|
|
30
|
+
/// sit beside the teal rather than fight it.
|
|
31
|
+
static let warn = NSColor(srgbRed: 0.851, green: 0.596, blue: 0.180, alpha: 1)
|
|
32
|
+
static let alert = NSColor(srgbRed: 0.804, green: 0.400, blue: 0.376, alpha: 1)
|
|
33
|
+
|
|
34
|
+
enum Radius {
|
|
35
|
+
static let sm: CGFloat = 6
|
|
36
|
+
static let base: CGFloat = 10
|
|
37
|
+
static let md: CGFloat = 12
|
|
38
|
+
static let lg: CGFloat = 18
|
|
39
|
+
static let pill: CGFloat = 999
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// `--nq-ease` — the single easing curve the system uses everywhere.
|
|
43
|
+
static let ease = CAMediaTimingFunction(controlPoints: 0.65, 0, 0.35, 1)
|
|
44
|
+
static let durFast: CFTimeInterval = 0.15
|
|
45
|
+
|
|
46
|
+
/// Meta type: mono, uppercase, tracked. Eyebrows and status only.
|
|
47
|
+
static func meta(size: CGFloat = 10.5) -> [NSAttributedString.Key: Any] {
|
|
48
|
+
[
|
|
49
|
+
.font: NSFont.monospacedSystemFont(ofSize: size, weight: .semibold),
|
|
50
|
+
.kern: size * 0.07,
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
static func metaString(_ text: String, size: CGFloat = 10.5, color: NSColor) -> NSAttributedString {
|
|
55
|
+
var attrs = meta(size: size)
|
|
56
|
+
attrs[.foregroundColor] = color
|
|
57
|
+
return NSAttributedString(string: text.uppercased(), attributes: attrs)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
static func body(size: CGFloat = 13, weight: NSFont.Weight = .medium) -> NSFont {
|
|
61
|
+
NSFont.systemFont(ofSize: size, weight: weight)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import ApplicationServices
|
|
3
|
+
|
|
4
|
+
/// What currently has keyboard focus.
|
|
5
|
+
///
|
|
6
|
+
/// This exists because of a near miss. A lesson located "the search box",
|
|
7
|
+
/// clicked it, and typed. The click landed on a navigation LINK rather
|
|
8
|
+
/// than an input, the page changed underneath, focus ended up somewhere
|
|
9
|
+
/// else entirely, and the next step pressed Return. On a social site the
|
|
10
|
+
/// thing that had focus could have been the compose box — so a search
|
|
11
|
+
/// query was one keystroke from being published.
|
|
12
|
+
///
|
|
13
|
+
/// Synthetic typing goes wherever focus is. It cannot tell a search field
|
|
14
|
+
/// from a post composer from a terminal. So the caller has to look first,
|
|
15
|
+
/// and that means a way to ask.
|
|
16
|
+
enum Focus {
|
|
17
|
+
|
|
18
|
+
struct Focused: Codable {
|
|
19
|
+
let role: String
|
|
20
|
+
let label: String
|
|
21
|
+
let value: String?
|
|
22
|
+
/// True when this can accept typing at all.
|
|
23
|
+
let editable: Bool
|
|
24
|
+
/// True when typing here plausibly PUBLISHES something. Heuristic
|
|
25
|
+
/// and deliberately over-inclusive: the cost of a false alarm is a
|
|
26
|
+
/// refused step, and the cost of a miss is a public post.
|
|
27
|
+
let publishRisk: Bool
|
|
28
|
+
let app: String
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Roles that accept text.
|
|
32
|
+
private static let editableRoles: Set<String> = [
|
|
33
|
+
"AXTextField", "AXTextArea", "AXComboBox", "AXSearchField",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
/// Words that suggest the field posts, sends or publishes. Checked
|
|
37
|
+
/// against the label, the placeholder and the nearest container — in
|
|
38
|
+
/// several languages, because the UI under test is not always English.
|
|
39
|
+
private static let publishWords = [
|
|
40
|
+
"post", "tweet", "compose", "reply", "publish", "send", "message",
|
|
41
|
+
"comment", "status", "quoi de neuf", "publier", "répondre", "envoyer",
|
|
42
|
+
"tweeter", "message", "commentaire",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
static func current() -> Focused? {
|
|
46
|
+
// Ask the frontmost application directly, the way AXTree does.
|
|
47
|
+
//
|
|
48
|
+
// Going through the system-wide element's focused-application
|
|
49
|
+
// attribute returned nothing here even with a text field focused,
|
|
50
|
+
// and a focus check that always fails is a gate that blocks
|
|
51
|
+
// everything — safe, and useless. Force-casting a CFTypeRef that
|
|
52
|
+
// came back nil also silently yields nil rather than an error,
|
|
53
|
+
// which is how this hid.
|
|
54
|
+
guard let app = NSWorkspace.shared.frontmostApplication else { return nil }
|
|
55
|
+
let axApp = AXUIElementCreateApplication(app.processIdentifier)
|
|
56
|
+
|
|
57
|
+
var elementRef: CFTypeRef?
|
|
58
|
+
guard AXUIElementCopyAttributeValue(axApp, kAXFocusedUIElementAttribute as CFString, &elementRef) == .success,
|
|
59
|
+
CFGetTypeID(elementRef) == AXUIElementGetTypeID()
|
|
60
|
+
else { return nil }
|
|
61
|
+
let element = unsafeBitCast(elementRef, to: AXUIElement.self)
|
|
62
|
+
|
|
63
|
+
let role = string(element, kAXRoleAttribute) ?? ""
|
|
64
|
+
let label = [kAXTitleAttribute, kAXDescriptionAttribute, kAXPlaceholderValueAttribute, kAXHelpAttribute]
|
|
65
|
+
.compactMap { string(element, $0) }
|
|
66
|
+
.first(where: { !$0.isEmpty }) ?? ""
|
|
67
|
+
let value = string(element, kAXValueAttribute)
|
|
68
|
+
|
|
69
|
+
// Also consider the enclosing container: a composer's textbox is
|
|
70
|
+
// often unlabelled while its parent says "Post".
|
|
71
|
+
var context = "\(label) \(string(element, kAXRoleDescriptionAttribute) ?? "")"
|
|
72
|
+
var parentRef: CFTypeRef?
|
|
73
|
+
if AXUIElementCopyAttributeValue(element, kAXParentAttribute as CFString, &parentRef) == .success,
|
|
74
|
+
let parent = parentRef as! AXUIElement? {
|
|
75
|
+
context += " " + ([kAXTitleAttribute, kAXDescriptionAttribute]
|
|
76
|
+
.compactMap { string(parent, $0) }.joined(separator: " "))
|
|
77
|
+
}
|
|
78
|
+
let haystack = context.lowercased()
|
|
79
|
+
let risky = publishWords.contains { haystack.contains($0) }
|
|
80
|
+
|
|
81
|
+
return Focused(
|
|
82
|
+
role: role,
|
|
83
|
+
label: label,
|
|
84
|
+
value: value,
|
|
85
|
+
editable: editableRoles.contains(role)
|
|
86
|
+
|| (string(element, kAXRoleDescriptionAttribute)?.lowercased().contains("text") ?? false),
|
|
87
|
+
publishRisk: risky,
|
|
88
|
+
app: app.localizedName ?? "unknown")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private static func string(_ node: AXUIElement, _ attr: String) -> String? {
|
|
92
|
+
var ref: CFTypeRef?
|
|
93
|
+
guard AXUIElementCopyAttributeValue(node, attr as CFString, &ref) == .success else { return nil }
|
|
94
|
+
if let s = ref as? String { return s.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
95
|
+
return nil
|
|
96
|
+
}
|
|
97
|
+
}
|